From 1396487af932291a948e6ba8f2654b805dbbbb73 Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Sun, 16 Aug 2026 05:31:45 +0800 Subject: [PATCH] =?UTF-8?q?refactor(page):=20=E5=88=A0=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E7=89=88=E9=A1=B5=E9=9D=A2=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/skills/dart-add-unit-test/SKILL.md | 2 +- .../dart-use-primary-constructors/SKILL.md | 262 ++++++++ .../SKILL.md | 185 ++++++ .../kotlin-tooling-agp9-migration/SKILL.md | 494 ++++++++++++++ .../assets/checklist.md | 53 ++ .../references/DSL-REFERENCE.md | 286 +++++++++ .../references/KNOWN-ISSUES.md | 546 ++++++++++++++++ .../references/MIGRATION-APP-SPLIT.md | 505 +++++++++++++++ .../references/MIGRATION-FULL-RESTRUCTURE.md | 478 ++++++++++++++ .../references/MIGRATION-LIBRARY.md | 561 ++++++++++++++++ .../references/PLUGIN-COMPATIBILITY.md | 59 ++ .../references/VERSION-MATRIX.md | 132 ++++ .../scripts/analyze-project.sh | 228 +++++++ .../SKILL.md | 523 +++++++++++++++ .../references/cocoapods-extras-patterns.md | 47 ++ .../references/common-pods-mapping.md | 474 ++++++++++++++ .../references/dsl-reference.md | 293 +++++++++ .../references/migration-report-template.md | 145 +++++ .../references/troubleshooting.md | 606 ++++++++++++++++++ .../SKILL.md | 172 +++++ .../kotlin-tooling-java-to-kotlin/SKILL.md | 138 ++++ .../assets/checklist.md | 60 ++ .../references/CONVERSION-METHODOLOGY.md | 352 ++++++++++ .../references/KNOWN-ISSUES.md | 358 +++++++++++ .../references/frameworks/DAGGER-HILT.md | 160 +++++ .../references/frameworks/GUICE.md | 165 +++++ .../references/frameworks/HIBERNATE.md | 227 +++++++ .../references/frameworks/JACKSON.md | 248 +++++++ .../references/frameworks/JUNIT.md | 193 ++++++ .../references/frameworks/LOMBOK.md | 237 +++++++ .../references/frameworks/MICRONAUT.md | 120 ++++ .../references/frameworks/MOCKITO.md | 253 ++++++++ .../references/frameworks/QUARKUS.md | 138 ++++ .../references/frameworks/RETROFIT.md | 151 +++++ .../references/frameworks/RXJAVA.md | 181 ++++++ .../references/frameworks/SPRING.md | 238 +++++++ .../SKILL.md | 154 +++++ .../evals/EVALUATION.md | 32 + .../references/artifacts-and-targets.md | 44 ++ .../references/caching-and-gradle.md | 68 ++ .../references/experimental.md | 32 + .../references/exports-and-generated-code.md | 50 ++ .../scripts/audit-native-build.sh | 162 +++++ .gitignore | 3 + AGENTS.md | 150 +++++ analysis_options.yaml | 9 + l10n.yaml | 5 + lib/app/app.dart | 34 + lib/app/constants/app_constants.dart | 21 + lib/app/router/app_router.dart | 63 ++ lib/app/theme/app_theme.dart | 20 + lib/core/network/api_exception.dart | 15 + lib/core/network/dio_client.dart | 80 +++ lib/core/storage/token_storage.dart | 121 ++++ lib/core/utils/device_info_util.dart | 37 ++ lib/features/auth/data/auth_providers.dart | 12 + .../auth/data/auth_repository_impl.dart | 100 +++ lib/features/auth/domain/auth_models.dart | 47 ++ lib/features/auth/domain/auth_repository.dart | 34 + lib/features/auth/domain/auth_state.dart | 39 ++ .../auth/presentation/auth_controller.dart | 137 ++++ .../auth/presentation/login_page.dart | 218 +++++++ .../auth/presentation/register_page.dart | 190 ++++++ .../auth/presentation/splash_page.dart | 71 ++ lib/features/home/presentation/home_page.dart | 67 ++ lib/features/home/presentation/home_tab.dart | 22 + .../messages/presentation/messages_tab.dart | 22 + .../profile/presentation/profile_tab.dart | 32 + lib/l10n/app_localizations.dart | 266 ++++++++ lib/l10n/app_localizations_en.dart | 76 +++ lib/l10n/app_localizations_zh.dart | 76 +++ lib/l10n/intl_en.arb | 25 + lib/l10n/intl_zh.arb | 91 +++ lib/main.dart | 59 +- lib/page/home_page.dart | 147 ----- lib/page/login_page.dart | 291 --------- lib/page/register_page.dart | 388 ----------- lib/page/splash_page.dart | 86 --- lib/utils/DeviceInfoUtil.dart | 58 -- linux/flutter/generated_plugin_registrant.cc | 8 + linux/flutter/generated_plugins.cmake | 2 + macos/Flutter/GeneratedPluginRegistrant.swift | 6 + pubspec.lock | 571 ++++++++++++++++- pubspec.yaml | 51 +- skills-lock.json | 104 ++- test/widget_test.dart | 40 +- .../flutter/generated_plugin_registrant.cc | 6 + windows/flutter/generated_plugins.cmake | 2 + 88 files changed, 12633 insertions(+), 1081 deletions(-) create mode 100644 .agents/skills/dart-use-primary-constructors/SKILL.md create mode 100644 .agents/skills/kotlin-backend-jpa-entity-mapping/SKILL.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/SKILL.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/assets/checklist.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/references/DSL-REFERENCE.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/references/KNOWN-ISSUES.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-APP-SPLIT.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-FULL-RESTRUCTURE.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-LIBRARY.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/references/PLUGIN-COMPATIBILITY.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/references/VERSION-MATRIX.md create mode 100644 .agents/skills/kotlin-tooling-agp9-migration/scripts/analyze-project.sh create mode 100644 .agents/skills/kotlin-tooling-cocoapods-spm-migration/SKILL.md create mode 100644 .agents/skills/kotlin-tooling-cocoapods-spm-migration/references/cocoapods-extras-patterns.md create mode 100644 .agents/skills/kotlin-tooling-cocoapods-spm-migration/references/common-pods-mapping.md create mode 100644 .agents/skills/kotlin-tooling-cocoapods-spm-migration/references/dsl-reference.md create mode 100644 .agents/skills/kotlin-tooling-cocoapods-spm-migration/references/migration-report-template.md create mode 100644 .agents/skills/kotlin-tooling-cocoapods-spm-migration/references/troubleshooting.md create mode 100644 .agents/skills/kotlin-tooling-immutable-collections-0-5-x-migration/SKILL.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/SKILL.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/assets/checklist.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/CONVERSION-METHODOLOGY.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/KNOWN-ISSUES.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/DAGGER-HILT.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/GUICE.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/HIBERNATE.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/JACKSON.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/JUNIT.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/LOMBOK.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/MICRONAUT.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/MOCKITO.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/QUARKUS.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/RETROFIT.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/RXJAVA.md create mode 100644 .agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/SPRING.md create mode 100644 .agents/skills/kotlin-tooling-native-build-performance/SKILL.md create mode 100644 .agents/skills/kotlin-tooling-native-build-performance/evals/EVALUATION.md create mode 100644 .agents/skills/kotlin-tooling-native-build-performance/references/artifacts-and-targets.md create mode 100644 .agents/skills/kotlin-tooling-native-build-performance/references/caching-and-gradle.md create mode 100644 .agents/skills/kotlin-tooling-native-build-performance/references/experimental.md create mode 100644 .agents/skills/kotlin-tooling-native-build-performance/references/exports-and-generated-code.md create mode 100644 .agents/skills/kotlin-tooling-native-build-performance/scripts/audit-native-build.sh create mode 100644 AGENTS.md create mode 100644 l10n.yaml create mode 100644 lib/app/app.dart create mode 100644 lib/app/constants/app_constants.dart create mode 100644 lib/app/router/app_router.dart create mode 100644 lib/app/theme/app_theme.dart create mode 100644 lib/core/network/api_exception.dart create mode 100644 lib/core/network/dio_client.dart create mode 100644 lib/core/storage/token_storage.dart create mode 100644 lib/core/utils/device_info_util.dart create mode 100644 lib/features/auth/data/auth_providers.dart create mode 100644 lib/features/auth/data/auth_repository_impl.dart create mode 100644 lib/features/auth/domain/auth_models.dart create mode 100644 lib/features/auth/domain/auth_repository.dart create mode 100644 lib/features/auth/domain/auth_state.dart create mode 100644 lib/features/auth/presentation/auth_controller.dart create mode 100644 lib/features/auth/presentation/login_page.dart create mode 100644 lib/features/auth/presentation/register_page.dart create mode 100644 lib/features/auth/presentation/splash_page.dart create mode 100644 lib/features/home/presentation/home_page.dart create mode 100644 lib/features/home/presentation/home_tab.dart create mode 100644 lib/features/messages/presentation/messages_tab.dart create mode 100644 lib/features/profile/presentation/profile_tab.dart create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_zh.dart create mode 100644 lib/l10n/intl_en.arb create mode 100644 lib/l10n/intl_zh.arb delete mode 100644 lib/page/home_page.dart delete mode 100644 lib/page/login_page.dart delete mode 100644 lib/page/register_page.dart delete mode 100644 lib/page/splash_page.dart delete mode 100644 lib/utils/DeviceInfoUtil.dart diff --git a/.agents/skills/dart-add-unit-test/SKILL.md b/.agents/skills/dart-add-unit-test/SKILL.md index dc27083..a4921a5 100644 --- a/.agents/skills/dart-add-unit-test/SKILL.md +++ b/.agents/skills/dart-add-unit-test/SKILL.md @@ -3,7 +3,7 @@ name: dart-add-unit-test description: Write and organize unit tests for functions, methods, and classes using `package:test`. Use when creating new logic or fixing bugs to ensure code remains correct and regression-free. metadata: model: models/gemini-3.1-pro-preview - last_modified: Fri, 24 Apr 2026 15:07:58 GMT + last_modified: Mon, 03 Aug 2026 21:51:24 GMT --- # Testing Dart and Flutter Applications diff --git a/.agents/skills/dart-use-primary-constructors/SKILL.md b/.agents/skills/dart-use-primary-constructors/SKILL.md new file mode 100644 index 0000000..13ba91c --- /dev/null +++ b/.agents/skills/dart-use-primary-constructors/SKILL.md @@ -0,0 +1,262 @@ +--- +name: dart-use-primary-constructors +description: > + Help users write syntactically and semantically correct primary constructors in Dart, and migrate/use the new constructor syntax, empty-body semicolon syntax, in-body initializer list syntax, and abbreviated concise constructor syntax. +metadata: + model: models/gemini-3.1-pro-preview + last_modified: Thu, 09 Jul 2026 23:13:25 GMT +--- + +# Dart Primary Constructors & New Constructor Syntax Skill + +Use this skill when helping users write, refactor, or debug code using Dart's **Primary Constructors** feature. + +### Dart Version Requirements +* **Dart 3.13 and above**: Primary constructors are enabled by default. +* **Dart 3.12**: The feature is available but experimental. Users must explicitly enable the experiment flag `primary-constructors` via `--enable-experiment=primary-constructors` or in `analysis_options.yaml`: +```yaml +analyzer: + enable-experiment: + - primary-constructors +``` +* **Dart 3.11 and earlier**: Primary constructors are not supported. + +--- + +## 1. Overview +Primary Constructors allow developers to declare a non-redirecting generative constructor as well as a set of instance variables directly in the class header. This significantly reduces boilerplate and improves code readability. + +### Key Benefits +- Combines field declaration, parameter declaration, and initialization into a single declaration known as a declaring parameter declaration. +- Enables safe reference to constructor parameters in non-late field initializers (Primary Initializer Scope). +- Allows empty declaration bodies to be represented concisely with a semicolon (`;`). +- Introduces abbreviated concise syntax for in-body constructors. + +--- + +## 2. Syntax Reference + +### 2.1 Basic Class Header Syntax +To declare a primary constructor, place a parameter list immediately after the type name (and optional type parameters): + +```dart +// Declares fields x and y, and a generative constructor Point(this.x, this.y) +class Point(var int x, var int y); + +// Declares final fields +class PointFinal(final int x, final int y); +``` + +### 2.2 Declaring, Initializing, and Plain Parameters +A primary constructor parameter list distinguishes between three types of parameters: +1. **Declaring Parameters**: Indicated by the `var` or `final` modifier (e.g., `final int x`). They implicitly create a corresponding instance field in the class. +2. **Initializing Parameters**: Indicated by the `this.` or `super.` prefix (e.g., `this.x` or `super.x`). They initialize an existing field or a super constructor parameter, respectively. +3. **Regular Parameters**: Declared without modifiers (e.g., `int y`). They do not become fields and are only available during initialization (e.g., in field initializers or the `this :` initializer list in the class body). + +```dart +// `x` is a field and a parameter because it has the keyword `final`. In particular, we can use the name `x` in the initializer list in the in-body part of the primary constructor. 'y' is a only parameter because it has neither of the keywords `final` or `var`, but `y` is passed to the super constructor via the `this :` initializer list. +class C(final int x, int y) extends Base { + this : super(y); +} +``` + +Declaring parameters and initializing parameters are two ways of achieving the same goal: declaring a class with instance fields which are set in the constructor. Regular parameters are different in that their values are not automatically routed to an instance field. + +### 2.3 Constant Primary Constructors +To make a primary constructor `const`, place the `const` keyword before the class/type name in the declaration header: + +```dart +class const Point(final int x, final int y); +extension type const Ext(int x); +enum const MyEnum(final int x) { + entry(1); +} +``` + +### 2.4 Extension Types +Extension types **must** use primary constructors. +- The single parameter in the header is the representation field. +- The representation variable cannot use the `var` modifier (using `var` triggers the `representation_field_modifier` error). +- The representation variable can optionally use the `final` modifier. If `final` is not present then it is inferred; that is, the parameter is declaring whether or not it's explicitly `final`. + +### 2.5 Empty Body Semicolon Shorthand (`;`) +When a class, mixin class, mixin, extension or extension type has an empty body, the `{}` braces can be replaced by a semicolon (`;`): + +```dart +class C(int x); +mixin class MC; +extension type ET(int x); +mixin M; +extension Ext on C; +``` + +### 2.6 The In-Body Part of a Primary Constructor (`this ...`) +If a primary constructor requires assertions or custom field initializations, they can be declared in the body using the `this :` syntax: + +```dart +class Point(var int x, var int y) { + // Initializer list in class body + this : assert(x >= 0), y = y * 2; +} +``` + +You can also write a constructor body with this syntax (`this {...}`). + +### 2.7 Abbreviated Concise Constructor Syntax +For constructors declared within the class body, the class name can be omitted and replaced with the `new` or `factory` keywords: + +| Traditional Syntax | Abbreviated Concise Syntax | +| :--- | :--- | +| `MyClass() {}` | `new() {}` | +| `MyClass.name() {}` | `new name() {}` | +| `const MyClass();` | `const new();` | +| `const MyClass.name();` | `const new name();` | +| `factory MyClass() => ...` | `factory() => ...` | +| `factory MyClass.name() => ...` | `factory name() => ...` | + +--- + +## 3. Semantics & Scoping Rules + +### 3.1 Primary Initializer Scope +When a primary constructor is declared, its formal parameters are introduced into the **Primary Initializer Scope**. This scope is the current scope for non-late field initializers in the class body and the primary constructor's initializer list (after `this :`). +This allows non-late fields to reference constructor parameters directly during declaration: + ```dart + class DeltaPoint(final int x, int delta) { + // 'x' and 'delta' are in scope here + final int y = x + delta; + } + ``` + +### 3.2 Late Instance Variables Restriction +The primary initializer scope is **not** active for `late` instance variable initializers. +- Since `late` variables can be evaluated after construction has completed, their initializers cannot safely access constructor parameters. +- Attempting to access a primary constructor parameter in a `late` field initializer results in a compile-time error. + +### 3.3 Shadowing +Primary constructor parameters shadow class members (fields) of the same name within the primary initializer scope: +- In a non-late initializer: `int y = x` refers to parameter `x`. +- In a `late` initializer: `late int y = x` refers to field `x` (if it exists) because the parameter `x` is out of scope. + +### 3.4 Generative Constructor Restrictions +To guarantee that the primary constructor (and the associated initializer scope) always executes: +- A class, mixin class, or enum declaration with a primary constructor **cannot** declare any other non-redirecting generative constructors (except extension types). +- All other generative constructors declared in the body **must** redirect (directly or indirectly) to the primary constructor. + +### 3.5 Parameter Mutation Errors +Primary constructor parameters are non-assignable inside the initialization phase. +- Any assignment to a parameter (e.g., `p = value`, `p++`) inside field initializers or the `this :` initializer list is a compile-time error. + +### 3.6 Double Initialization Errors +Initializing a field twice (e.g., once in the field declaration/initializer and once in the `this :` initializer list or as an initializing formal) is a compile-time error. + +--- + +## 4. Diagnostics & Troubleshooting + +Most errors and lints have quick-fixes, run `dart fix` to fix those violations. For other common errors, fix them using the following table: + +| Error / Lint Code | Common Cause | Resolution | +| :--- | :--- | :--- | +| **Invalid Late Access** | Referencing a primary constructor parameter inside a `late` field initializer. | Make the field non-late, or pass the value through another non-late field. | +| `fieldInitializedInInitializerAndDeclaration` | Initializing a variable both in its declaration and in the `this :` list. | Remove one of the initializations. | +| `nonRedirectingGenerativeConstructorWithPrimary` | Declaring a in-body generative constructor in the body without redirecting to the primary. | Change the in-body constructor such that it is redirecting (e.g. `this(...)`) or remove the in-body constructor. | + +--- + +## 5. Step-by-Step Refactoring Workflows + +### Workflow 5.1: Migrating a Class to a Primary Constructor + +Follow these steps to migrate a verbose class to the new primary constructor syntax: + +1. **Identify Candidate Fields and Constructor**: + Locate generative constructors and the fields they initialize. In this case, this would be the `name` and `age` fields. + ```dart + // Before + class User { + final String name; + final int age; + User(this.name, this.age); + } + ``` + +2. **Move Fields to the Header**: + Place fields in the header with `final` or `var` modifiers and append a semicolon (`;`) if the body is empty. The `name` and `age` fields are now written the primary constructor as declaring parameters `final String name` and `final int age`, respectively. + ```dart + // After + class User(final String name, final int age); + ``` + +3. **Handle Custom Initializers and Assertions**: + If there is an initializer list or assert block, move it to a `this` block inside the body: + ```dart + // Before + class Point { + final int x; + final int y; + Point(this.x, this.y) : assert(x >= 0); + } + + // After + class Point(final int x, final int y) { + this : assert(x >= 0); + } + ``` + +4. **Leverage Primary Initializer Scope for Calculations**: + If a field value is calculated from parameters, declare it inside the body and assign it directly using the parameters: + ```dart + // Before + class Rect { + final double width; + final double height; + final double area; + Rect(this.width, this.height) : area = width * height; + } + + // After + class Rect(final double width, final double height) { + // 'width' and 'height' are in scope here + final double area = width * height; + } + ``` + +5. **Convert In-Body Constructors to Redirecting**: + Ensure all in-body generative constructors redirect to the primary constructor: + ```dart + // Before + class Point { + final int x; + final int y; + Point(this.x, this.y); + Point.zero() : x = 0, y = 0; + } + + // After + class Point(final int x, final int y) { + new zero() : this(0, 0); // Redirects to primary + } + ``` + +### Workflow 5.2: Applying Abbreviated (Concise) In-Body Constructors + +When the user prefers to keep the constructor in the class body but wants to reduce verbosity, suggest the abbreviated constructor syntax: + +```dart +// Before +class DatabaseService { + final String url; + DatabaseService(this.url); + DatabaseService.local() : url = 'localhost'; + factory DatabaseService.create() => DatabaseService('default'); +} + +// After +class DatabaseService { + final String url; + new(this.url); // Omit class name, use 'new' + new local() : url = 'localhost'; // Use 'new local' for named constructors + factory create() => DatabaseService('default'); // Omit class name from factory +} +``` diff --git a/.agents/skills/kotlin-backend-jpa-entity-mapping/SKILL.md b/.agents/skills/kotlin-backend-jpa-entity-mapping/SKILL.md new file mode 100644 index 0000000..f31e380 --- /dev/null +++ b/.agents/skills/kotlin-backend-jpa-entity-mapping/SKILL.md @@ -0,0 +1,185 @@ +--- +name: kotlin-backend-jpa-entity-mapping +description: > + Model Kotlin persistence code correctly for Spring Data JPA and Hibernate. + Covers entity design, identity and equality, uniqueness constraints, + relationships, fetch plans, and common ORM (Object-Relational Mapping) traps + specific to Kotlin. Use when creating or reviewing JPA (Java Persistence API) + entities, diagnosing N+1 or LazyInitializationException, placing indexes and + uniqueness rules, or preventing Kotlin-specific bugs such as data class + entities and broken equals/hashCode. +license: Apache-2.0 +metadata: + author: JetBrains + version: "1.0.0" +--- + +# JPA Entity Mapping for Kotlin + +Kotlin's `data class` is natural for DTOs but dangerous for JPA entities. Hibernate relies on +identity semantics that `data class` breaks: `equals`/`hashCode` over all fields corrupts +`Set`/`Map` membership after state changes, and auto-generated `copy()` creates detached +duplicates of managed entities. + +This skill teaches correct entity design, identity strategies, and uniqueness constraints +for Kotlin + Spring Data JPA projects. + +## Entity Design Rules + +- **Never use `data class` for JPA entities.** Use a regular `class`. Keep `data class` for DTOs. +- Keep transport DTOs and persistence entities separate unless the project clearly uses a shared model. +- Model required columns as non-null only when object construction and persistence lifecycle make it safe. +- Use `lateinit` only when the project already accepts that tradeoff and the lifecycle is safe. +- Verify `kotlin("plugin.jpa")` or equivalent no-arg support when JPA entities exist. +- Verify classes and members are compatible with proxying where needed. + +## Identity and Equality + +- Never accept all-field `equals`/`hashCode` generated by `data class` on an entity. +- Follow project conventions when they already define an identity strategy. +- If no convention exists, use ID-based equality with a stable `hashCode`. +- For DB-generated IDs, model the unsaved state with nullable `var id: Long? = null` + and a `protected set`; do not use `0L` as a sentinel value. +- Be explicit about mutable fields and lazy associations when discussing equality. + +### Broken: `data class` Entity + +```kotlin +// WRONG: data class generates equals/hashCode from ALL fields, +// and the generated ID uses a 0 sentinel instead of null +data class Order( + @Id @GeneratedValue val id: Long = 0, + var status: String, + var total: BigDecimal +) +// BUG: order.status = "SHIPPED"; set.contains(order) → false (hash changed) +// BUG: Hibernate proxy.equals(entity) → false (proxy has lazy fields uninitialized) +``` + +### Correct: Regular Class with ID-Based Identity + +```kotlin +@Entity +@Table(name = "orders") +class Order( + @Column(nullable = false) + var status: String, + + @Column(nullable = false) + var total: BigDecimal +) { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + protected set + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Order) return false + return id != null && id == other.id + } + + override fun hashCode(): Int = javaClass.hashCode() + + // toString must NOT reference lazy collections + override fun toString(): String = "Order(id=$id, status=$status)" +} +``` + +**Key rules:** +- `equals` compares by ID only — stable under dirty tracking and proxy unwrapping +- `hashCode` returns class-based constant — avoids `Set`/`Map` corruption after persist +- `toString` excludes lazy-loaded relations — prevents `LazyInitializationException` +- Constructor params are mutable entity fields; DB-generated `id` is nullable with a protected setter + +## Uniqueness Constraints + +When an API must be idempotent (e.g., "reserve stock for order X"), enforce uniqueness +at both layers: database constraint for correctness, application check for clean errors. + +### Broken: No Duplicate Guard + +```kotlin +@Service +class ReservationService(private val repo: ReservationRepository) { + @Transactional + fun createReservation(variantId: Long, orderId: String, qty: Int): Reservation { + // BUG: no check — duplicates silently accumulate + return repo.save(Reservation(variantId = variantId, orderId = orderId, quantity = qty)) + } +} +``` + +### Correct: Database Constraint + Application Guard + +```kotlin +@Entity +@Table( + name = "reservations", + uniqueConstraints = [ + UniqueConstraint(columnNames = ["variant_id", "order_id"]) + ] +) +class Reservation( + @Column(name = "variant_id", nullable = false) + val variantId: Long, + + @Column(name = "order_id", nullable = false) + val orderId: String, + + @Column(nullable = false) + var quantity: Int +) { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + protected set +} + +interface ReservationRepository : JpaRepository { + fun findByVariantIdAndOrderId(variantId: Long, orderId: String): Reservation? +} + +@Service +class ReservationService(private val repo: ReservationRepository) { + @Transactional + fun createReservation(variantId: Long, orderId: String, qty: Int): Reservation { + repo.findByVariantIdAndOrderId(variantId, orderId)?.let { + throw IllegalStateException( + "Reservation already exists for variant=$variantId, order=$orderId" + ) + } + return repo.save(Reservation(variantId = variantId, orderId = orderId, quantity = qty)) + } +} +``` + +**Key rules:** +- Database constraint is mandatory — application checks alone have race conditions +- Application check provides clean error messages — without it, users get raw `DataIntegrityViolationException` +- Both layers together: application catches the common case, database catches the race +- Spring Data derives `findByXAndY` queries automatically + +## Query and Fetch Rules + +- Diagnose N+1 by looking at actual query count or SQL logs, not by guessing from annotations. +- Prefer targeted fetch solutions: `@EntityGraph`, `JOIN FETCH`, batch fetching, or DTO projection. +- Be careful with collection fetch joins plus pagination — call out the tradeoff. +- Use indexes and uniqueness constraints to support real query patterns. + +## Common ORM Traps + +- **Bidirectional associations:** maintain both sides in domain methods. Half-updated graphs cause subtle bugs. +- **`orphanRemoval` vs cascade remove:** not interchangeable. Explain lifecycle semantics before choosing. +- **Lazy load triggers:** `toString`, debug logging, JSON serialization, and IDE inspection can all trigger lazy loads. +- **Bulk updates/deletes:** bypass persistence context and lifecycle callbacks. Subsequent reads may be stale. +- **Multiple bag fetches:** can cause Cartesian explosion. Verify the ORM can execute collection-heavy fetch plans safely. +- **`Set` + mutable equality:** collection membership can break after entity state changes. +- **`@Version`:** the clearest optimistic concurrency mechanism when concurrent updates matter. +- **`open-in-view` disabled:** DTO mapping touching lazy fields must happen inside a transaction boundary. + +## Guardrails + +- Do not use `data class` for JPA entities. +- Do not recommend `FetchType.EAGER` everywhere to silence lazy loading symptoms. +- Do not expose entities directly through API responses by default. +- Do not claim an N+1 fix without explaining how the fetch plan changes query behavior. diff --git a/.agents/skills/kotlin-tooling-agp9-migration/SKILL.md b/.agents/skills/kotlin-tooling-agp9-migration/SKILL.md new file mode 100644 index 0000000..531e2e5 --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/SKILL.md @@ -0,0 +1,494 @@ +--- +name: kotlin-tooling-agp9-migration +description: > + Migrates Kotlin Multiplatform (KMP) projects to Android Gradle Plugin 9.0+. + Handles plugin replacement (com.android.kotlin.multiplatform.library), module + splitting, DSL migration, and the new default project structure. Use when + upgrading AGP, when build fails due to KMP+AGP incompatibility, or when the + user mentions AGP 9.0, android multiplatform plugin, KMP migration, or + com.android.kotlin.multiplatform.library. +license: Apache-2.0 +metadata: + author: JetBrains + version: "1.0.0" +--- + +# KMP AGP 9.0 Migration + +Android Gradle Plugin 9.0 makes the Android application and library plugins incompatible +with the Kotlin Multiplatform plugin in the same module. This skill guides you through the +migration. + +## Step 0: Analyze the Project + +Before making any changes, understand the project structure: +1. Read `settings.gradle.kts` (or `.gradle`) to find all modules +2. For each module, read its `build.gradle.kts` to identify which plugins are applied +3. Check if the project uses a Gradle version catalog (`gradle/libs.versions.toml`). If it exists, + read it for current AGP/Gradle/Kotlin versions. If not, find versions directly in `build.gradle.kts` + files (typically in the root `buildscript {}` or `plugins {}` block). **Adapt all examples in this + guide accordingly** — version catalog examples use `alias(libs.plugins.xxx)` while direct usage + uses `id("plugin.id") version "x.y.z"` +4. Read `gradle/wrapper/gradle-wrapper.properties` for the Gradle version +5. Check `gradle.properties` for any existing workarounds (`android.enableLegacyVariantApi`) +6. Check for `org.jetbrains.kotlin.android` plugin usage — AGP 9.0 has built-in Kotlin and this plugin must be removed +7. Check for `org.jetbrains.kotlin.kapt` plugin usage — incompatible with built-in Kotlin, must migrate to KSP or `com.android.legacy-kapt` +8. Check for third-party plugins that may be incompatible with AGP 9.0 (see "Plugin Compatibility" section below) + +If Bash is available, run `scripts/analyze-project.sh` from this skill's directory to get a structured summary. + +### Classify Each Module + +For each module, determine its type: + +| Current plugins | Migration path | +|--------------------------------------------------------------------------|---------------------------------------------| +| `kotlin.multiplatform` + `com.android.library` | **Path A** — Library plugin swap | +| `kotlin.multiplatform` + `com.android.application` | **Path B** — Mandatory Android split | +| `kotlin.multiplatform` with multiple platform entry points in one module | **Path C** — Full restructure (recommended) | +| `com.android.application` or `com.android.library` (no KMP) | See "Pure Android Tips" below | + +### Determine Scope + +- **Path B is mandatory** for any module combining KMP + Android application plugin +- **Path C is recommended** when the project has a monolithic `composeApp` (or similar) module + containing entry points for multiple platforms (Android, Desktop, Web). This aligns with the + new JetBrains default project structure where each platform gets its own app module. +- **Ask the user** whether they want Path B only (minimum required) or Path C (recommended full restructure) + +## Path A: Library Module Migration + +Use this when a module applies `kotlin.multiplatform` + `com.android.library`. + +See [references/MIGRATION-LIBRARY.md](references/MIGRATION-LIBRARY.md) for full before/after code. + +Summary: + +1. **Replace plugin**: `com.android.library` → `com.android.kotlin.multiplatform.library` +2. **Remove `org.jetbrains.kotlin.android`** plugin if present (AGP 9.0 has built-in Kotlin support) +3. **Migrate DSL**: Move config from top-level `android {}` block into `kotlin { android {} }`: + ```kotlin + kotlin { + android { + namespace = "com.example.lib" + compileSdk = 35 + minSdk = 24 + } + } + ``` +4. **Rename source directories** (only if the module uses classic Android layout instead of KMP layout): + - `src/main` → `src/androidMain` + - `src/test` → `src/androidHostTest` + - `src/androidTest` → `src/androidDeviceTest` + - If the module already uses `src/androidMain/`, no directory renames are needed +5. **Move dependencies** from top-level `dependencies {}` into `sourceSets`: + ```kotlin + kotlin { + sourceSets { + androidMain.dependencies { + implementation("androidx.appcompat:appcompat:1.7.0") + } + } + } + ``` +6. **Enable resources** explicitly if the module uses Android or Compose Multiplatform resources: + ```kotlin + kotlin { + android { + androidResources { enable = true } + } + } + ``` +7. **Enable Java** compilation if module has `.java` source files: + ```kotlin + kotlin { + android { + withJava() + } + } + ``` +8. **Enable tests** explicitly if the module has unit or instrumented tests: + ```kotlin + kotlin { + android { + withHostTest { isIncludeAndroidResources = true } + withDeviceTest { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + } + ``` +9. **Update Compose tooling dependency**: + ```kotlin + // Old: + debugImplementation(libs.androidx.compose.ui.tooling) + // New: + androidRuntimeClasspath(libs.androidx.compose.ui.tooling) + ``` +10. **Publish consumer ProGuard rules** explicitly if applicable: + ```kotlin + kotlin { + android { + consumerProguardFiles.add(file("consumer-rules.pro")) + } + } + ``` +11. **Resolve Sub-dependency Variants (Product Flavors / Build Types)**: + Because the new KMP Android library plugin enforces a single-variant architecture, it does not natively understand how to resolve dependencies that publish multiple variants (like `debug`/`release` build types, or product flavors like `free`/`paid`). Configure fallback behaviors using `localDependencySelection`: + ```kotlin + kotlin { + android { + localDependencySelection { + // Determine which build type to consume from Android library dependencies, in order of preference + selectBuildTypeFrom.set(listOf("debug", "release")) + + // If the dependency has a 'tier' dimension, select the 'free' flavor + productFlavorDimension("tier") { + selectFrom.set(listOf("free")) + } + } + } + } + ``` + +## Path B: Android App + Shared Module Split + +Use this when a module applies `kotlin.multiplatform` + `com.android.application`. This is **mandatory** for AGP 9.0 compatibility. + +See [references/MIGRATION-APP-SPLIT.md](references/MIGRATION-APP-SPLIT.md) for full guide. + +Summary: + +1. **Create `androidApp` module** with its own `build.gradle.kts`: + ```kotlin + plugins { + alias(libs.plugins.androidApplication) + // Do NOT apply kotlin-android — AGP 9.0 includes Kotlin support + alias(libs.plugins.composeMultiplatform) // if using Compose + alias(libs.plugins.composeCompiler) // if using Compose + } + + android { + namespace = "com.example.app" + compileSdk = 35 + defaultConfig { + applicationId = "com.example.app" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + buildFeatures { compose = true } + } + + dependencies { + implementation(projects.shared) // or whatever the shared module is named + implementation(libs.androidx.activity.compose) + } + ``` +2. **Move Android entry point code** from `src/androidMain/` to `androidApp/src/main/`: + - `MainActivity.kt` (and any other Activities/Fragments) + - `AndroidManifest.xml` (app-level manifest with `` and launcher ``) — verify `android:name` on `` uses the fully qualified class name in its new location + - Android Application class if present + - App-level resources (launcher icons, theme, etc.) +3. **Add to `settings.gradle.kts`**: `include(":androidApp")` +4. **Add to root `build.gradle.kts`**: plugin declarations with `apply false` +5. **Convert original module** from application to library using Path A steps +6. **Ensure different namespaces**: app module and library module must have distinct namespaces +7. **Remove from shared module**: `applicationId`, `targetSdk`, `versionCode`, `versionName` +8. **Update IDE run configurations**: change the module from the old module to `androidApp` + +## Path C: Full Restructure (Recommended) + +Use this when the project has a monolithic module (typically `composeApp`) containing entry +points for multiple platforms. This is optional but aligns with the new JetBrains default. + +See [references/MIGRATION-FULL-RESTRUCTURE.md](references/MIGRATION-FULL-RESTRUCTURE.md) for full guide. + +### Target Structure + +``` +project/ +├── shared/ ← KMP library (was composeApp), pure shared code +├── androidApp/ ← Android entry point only +├── desktopApp/ ← Desktop entry point only (if desktop target exists) +├── webApp/ ← Wasm/JS entry point only (if web target exists) +├── iosApp/ ← iOS Xcode project (usually already separate) +└── ... +``` + +### Steps + +1. **Apply Path B first** — extract `androidApp` (mandatory for AGP 9.0) +2. **Extract `desktopApp`** (if desktop target exists): + - Create module with `org.jetbrains.compose` and `application {}` plugin + - Move `main()` function from `desktopMain` to `desktopApp/src/main/kotlin/` + - Move `compose.desktop { application { ... } }` config to `desktopApp/build.gradle.kts` + - Add dependency on `shared` module +3. **Extract `webApp`** (if wasmJs/js target exists): + - Create module with appropriate Kotlin/JS or Kotlin/Wasm configuration + - Move web entry point from `wasmJsMain`/`jsMain` to `webApp/src/wasmJsMain/kotlin/` + - Move browser/distribution config to `webApp/build.gradle.kts` + - Add dependency on `shared` module +4. **iOS** — typically already in a separate `iosApp` directory. Verify: + - Framework export config (`binaries.framework`) stays in `shared` module + - Xcode project references the correct framework path +5. **Rename module** from `composeApp` to `shared`: + - Rename directory + - Update `settings.gradle.kts` include + - Update all dependency references across modules +6. **Clean up shared module**: remove all platform entry point code and app-specific config + that was moved to the platform app modules + +### Variant: Native UI + +If some platforms use native UI (e.g., SwiftUI for iOS), split `shared` into: +- `sharedLogic` — business logic consumed by ALL platforms +- `sharedUI` — Compose Multiplatform UI consumed only by platforms using shared UI + +### Variant: Server + +If the project includes a server target: +- Add `server` module at the root +- Move all client modules under an `app/` directory +- Add `core` module for code shared between server and client (models, validation) + +## Version Updates + +These are required regardless of migration path: + +1. **Gradle wrapper** — update to 9.1.0+: + ```properties + # gradle/wrapper/gradle-wrapper.properties + distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip + ``` +2. **AGP version** — update to 9.0.0+ and add the KMP library plugin. + + With version catalog (`gradle/libs.versions.toml`): + ```toml + [versions] + agp = "9.0.1" + + [plugins] + android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } + ``` + + Without version catalog — update `com.android.*` plugin versions and add in root `build.gradle.kts`: + ```kotlin + plugins { + id("com.android.application") version "9.0.1" apply false + id("com.android.kotlin.multiplatform.library") version "9.0.1" apply false + } + ``` +3. **JDK** — ensure JDK 17+ is used (required by AGP 9.0) +4. **SDK Build Tools** — update to 36.0.0: + ``` + Install via SDK Manager or configure in android { buildToolsVersion = "36.0.0" } + ``` +5. **Review gradle.properties** — remove error-causing properties and review changed defaults (see "Gradle Properties Default Changes" section) + +## Built-in Kotlin Migration + +AGP 9.0 enables built-in Kotlin support by default for all `com.android.application` and `com.android.library` +modules. The `org.jetbrains.kotlin.android` plugin is no longer needed and will conflict if applied. + +**Important:** Built-in Kotlin does NOT replace KMP support. KMP library modules still need +`org.jetbrains.kotlin.multiplatform` + `com.android.kotlin.multiplatform.library`. + +### Step 1: Remove kotlin-android Plugin + +Remove from **all** module-level and root-level build files: + +```kotlin +// Remove from module build.gradle.kts +plugins { + // REMOVE: alias(libs.plugins.kotlin.android) + // REMOVE: id("org.jetbrains.kotlin.android") +} + +// Remove from root build.gradle.kts +plugins { + // REMOVE: alias(libs.plugins.kotlin.android) apply false +} +``` + +Remove from version catalog (`gradle/libs.versions.toml`): +```toml +[plugins] +# REMOVE: kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +``` + +### Step 2: Migrate kapt to KSP or legacy-kapt + +The `org.jetbrains.kotlin.kapt` plugin is **incompatible** with built-in Kotlin. + +**Preferred: Migrate to KSP** — see the KSP migration guide for each annotation processor. + +**Fallback: Use `com.android.legacy-kapt`** (same version as AGP): +```toml +# gradle/libs.versions.toml +[plugins] +legacy-kapt = { id = "com.android.legacy-kapt", version.ref = "agp" } +``` +```kotlin +// Module build.gradle.kts — replace kotlin-kapt with legacy-kapt +plugins { + // REMOVE: alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.legacy.kapt) +} +``` + +### Step 3: Migrate kotlinOptions to compilerOptions + +For pure Android modules (non-KMP), migrate `android.kotlinOptions {}` to the top-level +`kotlin.compilerOptions {}`: +```kotlin +// Old +android { + kotlinOptions { + jvmTarget = "11" + languageVersion = "2.0" + freeCompilerArgs += listOf("-Xopt-in=kotlin.RequiresOptIn") + } +} + +// New +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + optIn.add("kotlin.RequiresOptIn") + } +} +``` + +**Note:** With built-in Kotlin, `jvmTarget` defaults to `android.compileOptions.targetCompatibility`, so it may be optional if you already set `compileOptions`. + +### Step 4: Migrate kotlin.sourceSets to android.sourceSets + +With built-in Kotlin, only `android.sourceSets {}` with the `kotlin` set is supported: +```kotlin +// NOT SUPPORTED with built-in Kotlin: +kotlin.sourceSets.named("main") { + kotlin.srcDir("additionalSourceDirectory/kotlin") +} + +// Correct: +android.sourceSets.named("main") { + kotlin.directories += "additionalSourceDirectory/kotlin" +} +``` + +For generated sources, use the Variant API: +```kotlin +androidComponents.onVariants { variant -> + variant.sources.kotlin!!.addStaticSourceDirectory("additionalSourceDirectory/kotlin") +} +``` + +### Per-Module Migration Strategy + +For large projects, migrate module-by-module: + +1. Disable globally: `android.builtInKotlin=false` in `gradle.properties` +2. Enable per migrated module by applying the opt-in plugin: + ```kotlin + plugins { + id("com.android.built-in-kotlin") version "AGP_VERSION" + } + ``` +3. Follow Steps 1-4 for that module +4. Once all modules are migrated, remove `android.builtInKotlin=false` and all `com.android.built-in-kotlin` plugins + +### Optional: Disable Kotlin for Non-Kotlin Modules + +For modules that contain **no Kotlin sources**, disable built-in Kotlin to save build time: +```kotlin +android { + enableKotlin = false +} +``` + +### Opt-Out (Temporary) + +If blocked by plugin incompatibilities, opt out temporarily: +```properties +# gradle.properties +android.builtInKotlin=false +android.newDsl=false # also required if using new DSL opt-out +``` + +**Warning:** Ask the user if they want to opt out, and if so, remind them this is a temporary measure. + +## Plugin Compatibility + +See [references/PLUGIN-COMPATIBILITY.md](references/PLUGIN-COMPATIBILITY.md) for the full compatibility table with known compatible versions, opt-out flag workarounds, and broken plugins. + +**Before migrating**, inventory all plugins in the project and check each against that table. If any plugin is broken without workaround, inform the user. If plugins need opt-out flags, add them to`gradle.properties` and note them as temporary workarounds. + +## Gradle Properties Default Changes + +AGP 9.0 changes the defaults for many Gradle properties. Check `gradle.properties` for any explicitly set values that may now conflict. +Key changes: + +| Property | Old Default | New Default | Action | +|------------------------------------------------------|-------------|-------------|---------------------------------------------------| +| `android.uniquePackageNames` | `false` | `true` | Ensure each library has a unique namespace | +| `android.enableAppCompileTimeRClass` | `false` | `true` | Refactor `switch` on R fields to `if/else` | +| `android.defaults.buildfeatures.resvalues` | `true` | `false` | Enable `resValues = true` where needed | +| `android.defaults.buildfeatures.shaders` | `true` | `false` | Enable shaders where needed | +| `android.r8.optimizedResourceShrinking` | `false` | `true` | Review R8 keep rules | +| `android.r8.strictFullModeForKeepRules` | `false` | `true` | Update keep rules to be explicit | +| `android.proguard.failOnMissingFiles` | `false` | `true` | Remove invalid ProGuard file references | +| `android.r8.proguardAndroidTxt.disallowed` | `false` | `true` | Use `proguard-android-optimize.txt` only | +| `android.r8.globalOptionsInConsumerRules.disallowed` | `false` | `true` | Remove global options from library consumer rules | +| `android.sourceset.disallowProvider` | `false` | `true` | Use `Sources` API on androidComponents | +| `android.sdk.defaultTargetSdkToCompileSdkIfUnset` | `false` | `true` | Specify `targetSdk` explicitly | +| `android.onlyEnableUnitTestForTheTestedBuildType` | `false` | `true` | Only if testing non-default build types | + +Check for and remove properties that now cause errors: +- `android.r8.integratedResourceShrinking` — removed, always on +- `android.enableNewResourceShrinker.preciseShrinking` — removed, always on + +## Pure Android Tips + +For non-KMP Android modules upgrading to AGP 9.0, follow the "Built-in Kotlin Migration" steps above, +then review the "Gradle Properties Default Changes" table. Additional changes: + +- **Review new DSL interfaces** — `BaseExtension` is removed; use `CommonExtension` or specific extension types +- **Java default changed** from Java 8 to Java 11 — ensure `compileOptions` reflects this + +## Verification + +After migration, verify with the [checklist](assets/checklist.md). Key checks: + +1. `./gradlew build` succeeds with no errors +2. All platform targets build successfully (Android, iOS via `xcodebuild`, Desktop, JS/Wasm) +3. `./gradlew :shared:allTests` and Android unit tests pass +4. No `com.android.library` or `com.android.application` in KMP modules +5. No `org.jetbrains.kotlin.android` in AGP 9.0 modules +6. Source sets use correct names (`androidMain`, `androidHostTest`, `androidDeviceTest`) +7. No deprecation warnings about variant API or DSL + +## Common Issues + +See [references/KNOWN-ISSUES.md](references/KNOWN-ISSUES.md) for details. Key gotchas: + +### KMP Library Plugin Issues +- **BuildConfig unavailable** in library modules — use DI/`AppConfiguration` interface, or use [BuildKonfig](https://github.com/yshrsmz/BuildKonfig) or [gradle-buildconfig-plugin](https://github.com/gmazzo/gradle-buildconfig-plugin) for compile-time constants +- **No build variants** — single variant architecture; compile-time constants can use BuildKonfig/gradle-buildconfig-plugin flavors, but variant-specific dependencies/resources/signing must move to app module +- **NDK/JNI unsupported** in new plugin — extract to separate `com.android.library` module +- **Compose resources crash** without `androidResources { enable = true }` +- **Consumer ProGuard rules silently dropped** if not migrated to `consumerProguardFiles.add(file(...))` in new DSL +- **KSP** requires version 2.3.1+ for AGP 9.0 compatibility + +### AGP 9.0 General Issues +- **BaseExtension removed** — convention plugins using old DSL types need rewriting to use `CommonExtension` +- **Variant APIs removed** — `applicationVariants`, `libraryVariants`, `variantFilter` replaced by `androidComponents` +- **Convention plugins** need refactoring — old `android {}` extension helpers are obsolete + +## Reference Files + +- [DSL Reference](references/DSL-REFERENCE.md) — side-by-side old→new DSL mapping +- [Version Matrix](references/VERSION-MATRIX.md) — AGP/Gradle/KGP/Compose/IDE compatibility +- [Plugin Compatibility](references/PLUGIN-COMPATIBILITY.md) — third-party plugin status and workarounds diff --git a/.agents/skills/kotlin-tooling-agp9-migration/assets/checklist.md b/.agents/skills/kotlin-tooling-agp9-migration/assets/checklist.md new file mode 100644 index 0000000..9d1e640 --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/assets/checklist.md @@ -0,0 +1,53 @@ +# KMP AGP 9.0 Migration Verification Checklist + +Use this checklist after migration to verify everything is configured correctly. + +## Plugin Configuration +- [ ] `com.android.kotlin.multiplatform.library` plugin declared for KMP library modules +- [ ] No `com.android.library` or `com.android.application` in KMP modules' build.gradle.kts +- [ ] `org.jetbrains.kotlin.android` removed from all build files and version catalog (built-in Kotlin replaces it) +- [ ] No `org.jetbrains.kotlin.kapt` plugin — migrated to KSP or `com.android.legacy-kapt` +- [ ] `android.kotlinOptions {}` migrated to `kotlin { compilerOptions {} }` (non-KMP modules) +- [ ] `kotlin.sourceSets` migrated to `android.sourceSets` with `.kotlin` accessor (non-KMP modules) +- [ ] No `android.builtInKotlin=false` unless required by incompatible plugin (documented as temporary) +- [ ] Third-party plugins verified compatible + +## KMP Library Modules +- [ ] Source sets renamed: `androidMain`, `androidHostTest`, `androidDeviceTest` +- [ ] No `android {}` top-level block — use `androidLibrary {}` inside `kotlin {}` instead +- [ ] `androidResources { enable = true }` present if module uses Android or Compose Multiplatform resources +- [ ] `withJava()` present if module has .java source files +- [ ] Tests configured: `withHostTest {}`, `withDeviceTest {}` +- [ ] No `debugImplementation` or analogs in library modules + - use `androidRuntimeClasspath` for tooling deps + - app modules can still use `debugImplementation` +- [ ] Unique `namespace` for each library module (different from app module; `android.uniquePackageNames=true` is default in AGP 9.0) + +## Gradle Properties & DSL +- [ ] No removed properties in `gradle.properties` that cause errors: + - `android.enableLegacyVariantApi` + - `android.r8.integratedResourceShrinking` + - `android.enableNewResourceShrinker.preciseShrinking` +- [ ] Any opt-out flags (`android.newDsl=false`, `android.builtInKotlin=false`) documented with reason +- [ ] `targetSdk` explicitly set in all app modules (defaults to `compileSdk` now, was `minSdk`) + +## Build Logic / Convention Plugins +- [ ] No references to `BaseExtension`, `AppExtension`, `LibraryExtension` (removed in AGP 9.0) +- [ ] Using `CommonExtension` or specific new DSL types +- [ ] No use of removed APIs: `applicationVariants`, `libraryVariants`, `variantFilter` + +## ProGuard / R8 +- [ ] Consumer ProGuard rules migrated to `consumerProguardFiles.add(file(...))` in new DSL +- [ ] Using `proguard-android-optimize.txt` (not `proguard-android.txt`) +- [ ] No global options (`-dontobfuscate`, `-dontoptimize`) in library consumer rules +- [ ] Keep rules updated for R8 strict full mode (explicit default constructor rules if needed) + +## Build & Test Verification +- [ ] `./gradlew build` succeeds +- [ ] `./gradlew :androidApp:assembleDebug` succeeds (if app module exists) +- [ ] `xcodebuild -project iosApp/*.xcodeproj -scheme -sdk iphonesimulator build` succeeds (if iOS app exists) +- [ ] Desktop app compiles: `./gradlew :desktopApp:run` or equivalent (if desktop target exists) +- [ ] Web/Wasm target compiles: `./gradlew :wasmJsApp:wasmJsBrowserDistribution` or equivalent (if web target exists) +- [ ] `./gradlew :shared:allTests` succeeds (or equivalent for KMP test tasks) +- [ ] `./gradlew :androidApp:testDebugUnitTest` succeeds (if app module exists) +- [ ] No deprecation warnings about variant API or DSL diff --git a/.agents/skills/kotlin-tooling-agp9-migration/references/DSL-REFERENCE.md b/.agents/skills/kotlin-tooling-agp9-migration/references/DSL-REFERENCE.md new file mode 100644 index 0000000..3f4c94f --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/references/DSL-REFERENCE.md @@ -0,0 +1,286 @@ +# DSL Reference: AGP 8.x to AGP 9.x KMP Library Migration + +Side-by-side mapping of every DSL element from the old `com.android.library` configuration to the new `com.android.kotlin.multiplatform.library` configuration. + +--- + +## Plugin IDs + +| Old (AGP 8.x) | New (AGP 9.x) | +|--------------------------------------|-----------------------------------------------------------------------------------------------------| +| `com.android.library` | `com.android.kotlin.multiplatform.library` | +| `com.android.application` | `com.android.application` (unchanged, but cannot combine with KMP) | +| `org.jetbrains.kotlin.android` | Built into `com.android.application` and `com.android.library` in AGP 9.0 (do not apply separately) | +| `org.jetbrains.kotlin.kapt` | `com.android.legacy-kapt` (same version as AGP) or migrate to KSP | +| `org.jetbrains.kotlin.multiplatform` | `org.jetbrains.kotlin.multiplatform` (unchanged) | + +--- + +## Top-Level Block Migration + +| Old | New | +|-------------------------------------------|-------------------------------------| +| `android { ... }` | `kotlin { android { ... } }` | +| `androidTarget { ... }` (in kotlin block) | `android { ... }` (in kotlin block) | + +--- + +## android {} Block Fields + +### Namespace and SDK Versions + +| Old (android {}) | New (kotlin { android {} }) | +|------------------------------------|-------------------------------------------------------------------------| +| `namespace = "..."` | `namespace = "..."` | +| `compileSdk = 35` | `compileSdk = 35` (same value, just moved into `kotlin { android {} }`) | +| `defaultConfig { minSdk = 24 }` | `minSdk = 24` | +| `defaultConfig { targetSdk = 34 }` | N/A (application-only, not in library) | + +### defaultConfig Elements + +| Old (android { defaultConfig {} }) | New (kotlin { android {} }) | +|--------------------------------------------|-------------------------------------------| +| `minSdk = 24` | `minSdk = 24` (direct property) | +| `testInstrumentationRunner = "..."` | Set in `withDeviceTest { }` configuration | +| `consumerProguardFiles("...")` | `consumerProguardFiles.add(file("..."))` | +| `multiDexEnabled = true` | N/A (handled automatically) | +| `vectorDrawables.useSupportLibrary = true` | N/A | +| `buildConfigField(...)` | Removed (see KNOWN-ISSUES.md) | +| `manifestPlaceholders[...]` | N/A (use merged manifest in app module) | + +--- + +## Compile Options and Compiler Options + +| Old | New | +|-------------------------------------------------------------------------------|------------------------------------------------------------------------------| +| `android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 } }` | `kotlin { android { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } }` | +| `android { compileOptions { targetCompatibility = JavaVersion.VERSION_11 } }` | `kotlin { android { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } }` | +| `kotlinOptions { jvmTarget = "11" }` | `compilerOptions { jvmTarget.set(JvmTarget.JVM_11) }` | +| `kotlinOptions { freeCompilerArgs += listOf("-Xopt-in=...") }` | `compilerOptions { optIn.add("...") }` | +| `kotlinOptions { languageVersion = "1.9" }` | `compilerOptions { languageVersion.set(KotlinVersion.KOTLIN_2_0) }` | + +Full JvmTarget import: +```kotlin +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +``` + +--- + +## Build Features + +| Old (android { buildFeatures {} }) | New (kotlin { android {} }) | +|-----------------------------------------|------------------------------------------------------------------------------| +| `buildFeatures { compose = true }` | Applied via compose compiler plugin (no explicit flag needed in KMP library) | +| `buildFeatures { buildConfig = true }` | Removed in KMP library (see KNOWN-ISSUES.md) | +| `buildFeatures { viewBinding = true }` | Not supported in KMP library | +| `buildFeatures { dataBinding = true }` | Not supported in KMP library | +| `buildFeatures { aidl = true }` | Not supported in KMP library | +| `buildFeatures { renderScript = true }` | Not supported | +| `buildFeatures { resValues = true }` | Not supported in KMP library | + +--- + +## Android Resources + +| Old | New | +|---------------------------------------------|-------------------------------------------------------------| +| Resources processed by default | Must explicitly enable | +| `android { ... }` (resources auto-included) | `kotlin { android { androidResources { enable = true } } }` | + +--- + +## Test Options + +| Old | New | +|----------------------------------------------------------------------|--------------------------------------------------------------------------------| +| `android { testOptions { unitTests.isReturnDefaultValues = true } }` | `kotlin { android { withHostTest { } } }` | +| `android { testOptions { animationsDisabled = true } }` | `kotlin { android { withDeviceTest { } } }` | +| Source set: `androidUnitTest` | Source set: `androidHostTest` (alias: `androidUnitTest` still works) | +| Source set: `androidInstrumentedTest` | Source set: `androidDeviceTest` (alias: `androidInstrumentedTest` still works) | +| `testImplementation(...)` | `getByName("androidHostTest").dependencies { implementation(...) }` | +| `androidTestImplementation(...)` | `getByName("androidDeviceTest").dependencies { implementation(...) }` | +| Source dir: `src/test/` | Source dir: `src/androidHostTest/kotlin/` | +| Source dir: `src/androidTest/` | Source dir: `src/androidDeviceTest/kotlin/` | + +### Test Configuration Details + +```kotlin +// Old +android { + testOptions { + unitTests { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } +} + +// New +kotlin { + android { + withHostTest { + // Host test specific configuration + // returnDefaultValues and includeAndroidResources + // are configured via gradle.properties or test runner + } + withDeviceTest { + // Device test specific configuration + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } +} +``` + +--- + +## Lint Configuration + +| Old | New | +|-------------------------------------------------------------|------------------------------------------------------------------------| +| `android { lint { abortOnError = false } }` | `kotlin { android { lint { abortOnError = false } } }` | +| `android { lint { checkReleaseBuilds = true } }` | `kotlin { android { lint { checkReleaseBuilds = true } } }` | +| `android { lint { disable += "SomeCheck" } }` | `kotlin { android { lint { disable += "SomeCheck" } } }` | +| `android { lint { baseline = file("lint-baseline.xml") } }` | `kotlin { android { lint { baseline = file("lint-baseline.xml") } } }` | + +The lint DSL is largely unchanged, it just moves inside `kotlin { android {} }`. + +**Note:** `useK2Uast` is deprecated. Remove it if present. + +--- + +## Packaging / Resources Excludes + +| Old | New | +|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `android { packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } }` | `kotlin { android { packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } }` | + +The DSL is the same, just nested under `kotlin { android {} }`. + +**Syntax note for AGP 9.0:** +```kotlin +// Old syntax (still works but deprecated) +resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + +// Preferred AGP 9.0 syntax +resources { + excludes.add("/META-INF/AL2.0") + excludes.add("/META-INF/LGPL2.1") +} +``` + +--- + +## Dependencies Configurations + +| Old Configuration | New Configuration | Notes | +|----------------------------------|-----------------------------------------------------------------------|-----------------------------| +| `implementation(...)` | `androidMain.dependencies { implementation(...) }` | Move to source set | +| `api(...)` | `androidMain.dependencies { api(...) }` | Move to source set | +| `compileOnly(...)` | `androidMain.dependencies { compileOnly(...) }` | Move to source set | +| `debugImplementation(...)` | `"androidRuntimeClasspath"(...)` | No variant-specific configs | +| `releaseImplementation(...)` | `androidMain.dependencies { implementation(...) }` | Single variant | +| `testImplementation(...)` | `getByName("androidHostTest").dependencies { implementation(...) }` | | +| `androidTestImplementation(...)` | `getByName("androidDeviceTest").dependencies { implementation(...) }` | | +| `ksp(...)` | `add("ksp", ...)` or KSP Gradle plugin DSL | Check KSP compatibility | +| `kapt(...)` | Migrate to KSP; kapt not supported | | + +--- + +## Dependency Resolution +Because the new KMP Android library plugin is strictly single-variant, you can no longer define fallback logic inside `buildTypes` or `defaultConfig`. +| Old | New | Notes | +|---|---|---| +| `android { defaultConfig { missingDimensionStrategy("tier", "free") } }` | `kotlin { android { localDependencySelection { productFlavorDimension("tier") { selectFrom.set(listOf("free")) } } } }` | Configure dependency flavor fallbacks | +| `android { buildTypes { getByName("debug") { matchingFallbacks.add("release") } } }` | `kotlin { android { localDependencySelection { selectBuildTypeFrom.set(listOf("debug", "release")) } } }` | Configure dependency build type mapping | + +--- + +## Build Types and Product Flavors + +**Removed in KMP library plugin.** The `com.android.kotlin.multiplatform.library` plugin produces a single build variant. + +| Old | New | Notes | +|---------------------------------------------------|---------|-----------------------------------------------------| +| `buildTypes { debug { ... } }` | Removed | Single variant only | +| `buildTypes { release { minifyEnabled = true } }` | Removed | Minification is app-module concern | +| `productFlavors { ... }` | Removed | Use Gradle properties or expect/actual for variants | +| `flavorDimensions(...)` | Removed | | + +### Workarounds for Variant-Dependent Logic + +1. **Compile-time constants:** Use `expect`/`actual` or dependency injection instead of `BuildConfig`. +2. **Environment-specific behavior:** Use Gradle properties or runtime configuration. +3. **Different dependencies per build type:** Not possible in KMP library. Move to app module. +4. **Minification/ProGuard:** Only relevant in the application module. + +--- + +## androidComponents Block + +| Old | New | +|------------------------------------------------|--------------------------------------------------| +| `androidComponents { onVariants { ... } }` | Limited support; most variant API is unavailable | +| `androidComponents { beforeVariants { ... } }` | Not available in KMP library | +| `androidComponents { finalizeDsl { ... } }` | Not available in KMP library | + +The `androidComponents` extension is significantly reduced in scope for KMP libraries because there is only a single variant. Most customization that relied on variant-aware APIs must be reworked. + +**Note:** `android.enableLegacyVariantApi` is **removed** in AGP 9.0 and will cause an error if set. Code depending on legacy variant APIs must be migrated to `androidComponents` APIs. + +--- + +## Java Source Compilation + +| Old | New | +|---------------------------------------------------------|-------------------------------------| +| Java sources in `src/main/java/` compiled automatically | Must call `withJava()` | +| `android { compileOptions { ... } }` | `kotlin { android { withJava() } }` | + +```kotlin +kotlin { + android { + withJava() // Required to compile .java files in androidMain + } +} +``` + +--- + +## Quick Reference: Minimal Migration Template + +```kotlin +// OLD +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) +} +kotlin { + androidTarget { compilations.all { kotlinOptions { jvmTarget = "11" } } } +} +android { + namespace = "com.example.lib" + compileSdk = 35 + defaultConfig { minSdk = 24 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +// NEW +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) +} +kotlin { + android { + namespace = "com.example.lib" + compileSdk = 35 + minSdk = 24 + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } +} +``` diff --git a/.agents/skills/kotlin-tooling-agp9-migration/references/KNOWN-ISSUES.md b/.agents/skills/kotlin-tooling-agp9-migration/references/KNOWN-ISSUES.md new file mode 100644 index 0000000..0571238 --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/references/KNOWN-ISSUES.md @@ -0,0 +1,546 @@ +# Known Issues: KMP AGP 9.0 Library Migration + +Comprehensive list of gotchas, limitations, and workarounds when migrating to `com.android.kotlin.multiplatform.library`. Based on official Android documentation and community experience. + +--- + +## 1. BuildConfig Removed in Libraries + +**Problem:** The `BuildConfig` class is not generated for KMP library modules. Code referencing `BuildConfig.DEBUG`, `BuildConfig.VERSION_NAME`, or custom `buildConfigField` entries will fail to compile. + +**Impact:** High. Many libraries use `BuildConfig.DEBUG` for logging gates and `buildConfigField` for compile-time constants. + +**Workaround -- AppConfiguration DI Pattern:** + +```kotlin +// In commonMain +expect class AppConfiguration { + val isDebug: Boolean + val versionName: String + val apiBaseUrl: String +} + +// In androidMain +actual class AppConfiguration(private val context: Context) { + actual val isDebug: Boolean = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + actual val versionName: String = context.packageManager + .getPackageInfo(context.packageName, 0).versionName ?: "unknown" + actual val apiBaseUrl: String = if (isDebug) "https://dev.api.example.com" else "https://api.example.com" +} + +// In iosMain +actual class AppConfiguration { + actual val isDebug: Boolean = Platform.isDebugBinary + actual val versionName: String = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "unknown" + actual val apiBaseUrl: String = if (isDebug) "https://dev.api.example.com" else "https://api.example.com" +} +``` + +Inject `AppConfiguration` via your DI framework (Koin, kotlin-inject, manual DI). + +**Alternative A — BuildKonfig plugin** ([github.com/yshrsmz/BuildKonfig](https://github.com/yshrsmz/BuildKonfig)): + +Generates `expect`/`actual` BuildConfig objects across all KMP targets. Supports typed fields +(String, Int, Long, Float, Boolean), target-specific overrides, and a flavor system via Gradle properties. + +```kotlin +// build.gradle.kts +plugins { + id("com.codingfeline.buildkonfig") +} + +buildkonfig { + packageName = "com.example.shared" + + defaultConfigs { + buildConfigField(STRING, "API_BASE_URL", "https://api.example.com") + buildConfigField(BOOLEAN, "IS_DEBUG", "false") + buildConfigField(STRING, "VERSION_NAME", "1.0.0") + } + + // Optional: target-specific overrides + targetConfigs { + create("android") { + buildConfigField(STRING, "PLATFORM", "android") + } + create("ios") { + buildConfigField(STRING, "PLATFORM", "ios") + } + } +} +``` + +Use flavors for debug/release by setting `buildkonfig.flavor=dev` in `gradle.properties` +or passing `-Pbuildkonfig.flavor=release` on CLI: + +```kotlin +defaultConfigs("dev") { + buildConfigField(STRING, "API_BASE_URL", "https://dev.api.example.com") + buildConfigField(BOOLEAN, "IS_DEBUG", "true") +} +defaultConfigs("release") { + buildConfigField(STRING, "API_BASE_URL", "https://api.example.com") + buildConfigField(BOOLEAN, "IS_DEBUG", "false") +} +``` + +**Alternative B — gradle-buildconfig-plugin** ([github.com/gmazzo/gradle-buildconfig-plugin](https://github.com/gmazzo/gradle-buildconfig-plugin)): + +More general-purpose; supports Java, Kotlin, Groovy, and KMP. Richer type support (arrays, maps, +Files, URIs). Uses `expect`/`actual` for KMP via explicit `expect()` calls. + +```kotlin +// build.gradle.kts +plugins { + id("com.github.gmazzo.buildconfig") +} + +buildConfig { + packageName("com.example.shared") + + buildConfigField("APP_NAME", project.name) + buildConfigField("VERSION", "1.0.0") + buildConfigField("IS_DEBUG", false) + + // Platform-specific fields using expect/actual + buildConfigField("PLATFORM", expect()) +} + +// In source set configurations: +sourceSets.named("androidMain") { + buildConfigField("PLATFORM", "android") +} +sourceSets.named("iosMain") { + buildConfigField("PLATFORM", "ios") +} +``` + +**Important limitation:** Neither plugin replaces Android build variants fully. They provide +compile-time constants only. Build type-specific dependencies, resources, source sets, signing +configs, and minification settings must be handled in the application module (which still supports +variants) or via runtime configuration. + +--- + +## 2. NDK / JNI Unsupported + +**Problem:** The KMP library plugin does not support `externalNativeBuild`, `ndkVersion`, or JNI source compilation. Modules that use C/C++ native code via NDK cannot be migrated directly. + +**Impact:** Medium. Affects modules with native image processing, crypto, or media libraries. + +**Workaround -- Proxy Interface Pattern:** + +Keep the JNI module as a classic `com.android.library` module and have the KMP module depend on it: + +``` +jni-bridge/ # com.android.library (AGP 8.x compatible in AGP 9.0) + build.gradle.kts + src/main/jni/ # C/C++ sources + src/main/kotlin/ # JNI bindings + +shared/ # com.android.kotlin.multiplatform.library + build.gradle.kts +``` + +```kotlin +// shared/build.gradle.kts +kotlin { + sourceSets { + androidMain.dependencies { + implementation(project(":jni-bridge")) + } + } +} +``` + +Define an interface in `commonMain` and implement it in `androidMain` by delegating to the JNI bridge. + +--- + +## 3. No Build Variants + +**Problem:** The KMP library plugin produces a single build variant. There are no `debug`/`release` build types and no product flavors. Code that depends on variant-specific behavior, resources, or dependencies must be restructured. + +**Impact:** High. Affects projects using flavor-specific dependencies, resources, or source sets. + +**Workaround -- Single Variant Architecture:** + +- Move all variant-dependent logic to the application module (which still supports variants). +- Use runtime configuration instead of compile-time variants. +- Use `expect`/`actual` with different actual implementations selected by DI based on runtime config. +- For library-specific debug/release behavior, use the `AppConfiguration` pattern from issue 1. +- For compile-time constants that vary by build flavor, use **BuildKonfig** or **gradle-buildconfig-plugin** (see issue 1 alternatives). These provide a flavor-like system for KMP but do NOT replace variant-specific dependencies, resources, signing, or minification. + +--- + +## 4. Compose Resources Require Explicit Enable + +**Problem:** Android resources (`res/` directory) are not processed by default with the KMP library plugin. If you forget to enable them, resource references (`R.string.*`, `R.drawable.*`) will fail to resolve. This is tracked as CMP-9547. + +**Impact:** High. Silent failure -- resources are simply ignored without an error until you try to reference them. + +**Fix:** + +```kotlin +kotlin { + android { + androidResources { enable = true } + } +} +``` + +**Note:** This is separate from Compose Multiplatform resources (`composeResources/`), which are handled by the compose resources plugin and do not need this flag. + +--- + +## 5. Consumer ProGuard Rules Silently Dropped + +**Problem:** If you had `consumerProguardFiles` in the old `android { defaultConfig {} }` block and did not migrate it to the new DSL location, the rules are silently ignored. No warning is emitted. + +**Impact:** Medium. Can cause runtime crashes in release builds of consuming applications. + +**Fix:** + +```kotlin +// Old (silently ignored) +android { + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + } +} + +// New +kotlin { + android { + consumerProguardFiles.add(file("consumer-rules.pro")) + } +} +``` + +--- + +## 6. Convention Plugin Refactoring Needed + +**Problem:** Build-logic convention plugins that apply `com.android.library` and configure the `LibraryExtension` must be rewritten to use the KMP library plugin and `KotlinMultiplatformExtension`. + +**Impact:** Medium to High for projects with extensive build-logic modules. + +**Key Changes:** + +```kotlin +// Old +import com.android.build.gradle.LibraryExtension + +class MyConventionPlugin : Plugin { + override fun apply(target: Project) { + target.pluginManager.apply("com.android.library") + target.extensions.configure { + compileSdk = 34 + defaultConfig.minSdk = 24 + } + } +} + +// New +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +class MyConventionPlugin : Plugin { + override fun apply(target: Project) { + target.pluginManager.apply("org.jetbrains.kotlin.multiplatform") + target.pluginManager.apply("com.android.kotlin.multiplatform.library") + target.extensions.configure { + android { + compileSdk = 35 + minSdk = 24 + } + } + } +} +``` + +--- + +## 7. Renamed test source sets + +**Problem:** The source set `androidUnitTest` is renamed to `androidHostTest`. The source set `androidInstrumentedTest` is renamed to `androidDeviceTest`. The old names still work as aliases but are deprecated. + +**Impact:** Low. Aliases provide backward compatibility, but you should rename for clarity. + +**Action Items:** +- Rename `src/androidUnitTest/` to `src/androidHostTest/` +- Rename `src/androidInstrumentedTest/` to `src/androidDeviceTest/` +- Update `sourceSets` references in `build.gradle.kts` +- Update CI scripts that reference the old directory names + +--- + +## 8. Lint useK2Uast Deprecated + +**Problem:** The `lint { useK2Uast = true }` option is deprecated. With KGP 2.0+ and AGP 9.0, K2 UAST is the default and only implementation. + +**Impact:** Low. Build warning only. + +**Fix:** Remove the line: + +```kotlin +// Remove this +lint { + useK2Uast = true // DELETE +} +``` + +--- + +## 9. Packaging Exclusions Syntax Change + +**Problem:** The packaging exclusions DSL has a subtle syntax difference. The old brace-expansion syntax may not work correctly. + +**Impact:** Low. Build may fail or produce unexpected results. + +**Fix:** + +```kotlin +// Old (may not work correctly in AGP 9.0) +packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} + +// New (explicit entries) +packaging { + resources { + excludes.add("/META-INF/AL2.0") + excludes.add("/META-INF/LGPL2.1") + } +} +``` + +--- + +## 10. Static BuildConfig.DEBUG for Tree-Shaking No Longer Available + +**Problem:** In classic Android libraries, `BuildConfig.DEBUG` was a `static final boolean` that the compiler could use for dead-code elimination (tree-shaking). Without BuildConfig in KMP libraries, this optimization path is lost. + +**Impact:** Low to Medium. Debug-only code paths may be included in release builds. + +**Workaround:** + +Use R8 rules in the application module to remove debug code: + +```proguard +# In the app module's proguard-rules.pro +-assumenosideeffects class com.example.shared.AppConfiguration { + boolean isDebug() return false; +} +``` + +Or use compile-time constants from the application module passed via DI. + +--- + +## 11. android.builtInKotlin=false (Temporary Opt-Out) + +**Problem:** AGP 9.0 bundles Kotlin compilation for `com.android.application` modules, meaning you should NOT apply the `kotlin-android` plugin separately. If you have tooling or plugins that conflict with the built-in Kotlin support, you can temporarily disable it. + +**Temporary Workaround:** + +```properties +# gradle.properties +android.builtInKotlin=false +``` + +**Warning:** This flag is temporary and will be removed in a future AGP version. Use it only to unblock migration while you fix plugin conflicts. + +**When you need this:** +- You have custom Kotlin compiler plugin configurations that conflict with AGP's built-in setup +- A third-party Gradle plugin explicitly applies `kotlin-android` and conflicts + +--- + +## 12. IDE Support Matrix + +| IDE | KMP Library Plugin Support | Notes | +|---|---|-----------------------------------------------------------| +| Android Studio Otter 3 (2025.2.3) | Full | First version with full AGP 9.0 support | +| Android Studio Narwhal (2025.1) | Partial | Can build but limited code insight for new DSL | +| Android Studio Meerkat (2024.3) | None | Does not recognize the KMP library plugin | +| IntelliJ IDEA | Not supported | Does not support AGP 9.0 as of 2025.3, use Android Studio | + +### Recommended IDE Setup + +- **Use Android Studio Otter 3 or later.** This is currently the only IDE with full AGP 9.0 + KMP library plugin support. +- **IntelliJ IDEA does not support AGP 9.0** — even with the Android plugin installed, project sync and build integration will not work correctly. If the project uses IntelliJ IDEA, recommend switching to Android Studio for Android/KMP development. +- Ensure the Kotlin plugin in your IDE matches the KGP version in your project. + +--- + +## 13. kapt Incompatible with Built-in Kotlin + +**Problem:** The `org.jetbrains.kotlin.kapt` plugin is incompatible with AGP 9.0's built-in Kotlin support. Applying both causes a build failure. + +**Impact:** High. Many projects still use kapt for annotation processing + +**Workaround:** + +**Preferred:** Migrate to KSP (Kotlin Symbol Processing). Most annotation processors now support KSP. + +**Fallback:** Replace with `com.android.legacy-kapt` (versioned with AGP): + +```toml +# gradle/libs.versions.toml +[plugins] +legacy-kapt = { id = "com.android.legacy-kapt", version.ref = "agp" } +``` + +```kotlin +// Module build.gradle.kts +plugins { + // REMOVE: alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.legacy.kapt) +} +``` + +--- + +## 14. New DSL Interfaces (BaseExtension Removed) + +**Problem:** AGP 9.0 exclusively uses new public DSL interfaces. The old `BaseExtension`, `AppExtension`, `LibraryExtension` types from `com.android.build.gradle` are removed. Build logic or convention plugins casting to these types will fail with `ClassCastException`. + +**Impact:** High for projects with custom build logic or convention plugins. + +**Error message:** +``` +java.lang.ClassCastException: class com.android.build.gradle.internal.dsl.ApplicationExtensionImpl$AgpDecorated_Decorated + cannot be cast to class com.android.build.gradle.BaseExtension +``` + +**Fix:** + +```kotlin +// Old +import com.android.build.gradle.BaseExtension +val ext = extensions.getByType(BaseExtension::class) + +// New +import com.android.build.api.dsl.CommonExtension +val ext = extensions.getByType(CommonExtension::class) +``` + +**Temporary opt-out:** `android.newDsl=false` in `gradle.properties` (removed in AGP 10.0). + +--- + +## 15. Deprecated Variant APIs Removed + +**Problem:** The following APIs are removed in AGP 9.0: `applicationVariants`, `libraryVariants`, `testVariants`, `unitTestVariants`, `variantFilter`. Build scripts or plugins using these will fail. + +**Impact:** Medium-High. Affects custom build logic and many third-party plugins. + +**Fix:** + +```kotlin +// Old +android { + applicationVariants.all { variant -> + variant.signingConfig.enableV1Signing = false + } +} + +// New +androidComponents { + onVariants { variant -> + variant.signingConfig.enableV1Signing.set(false) + } +} +``` + +Replace `variantFilter` with `androidComponents.beforeVariants()`. + +--- + +## 16. R8 and ProGuard Rule Changes + +**Problem:** AGP 9.0 changes several R8 defaults: + +- `android.r8.strictFullModeForKeepRules=true` — keep rules no longer implicitly keep default constructors +- `android.r8.proguardAndroidTxt.disallowed=true` — only `proguard-android-optimize.txt` is supported +- `android.r8.globalOptionsInConsumerRules.disallowed=true` — library consumer rules cannot contain global options (like `-dontobfuscate`) +- Keep rules no longer propagate to synthesized companion methods + +**Impact:** Medium. Release builds may crash or behave differently without rule updates. + +**Fix:** +- Review all ProGuard/R8 keep rules; add explicit rules for default constructors if needed +- Switch to `proguard-android-optimize.txt` in `getDefaultProguardFile()` +- Remove global options (`-dontobfuscate`, `-dontoptimize`) from library consumer rules +- New option: `-processkotlinnullchecks keep|remove_message|remove` to control Kotlin null checks + +--- + +## 17. Removed Features + +**Problem:** Several features are removed in AGP 9.0 with no replacement: + +- **Embedded Wear OS app support** — `wearApp` configurations removed +- **Density split APK** — use app bundles instead +- **`androidDependencies` and `sourceSets` report tasks** — removed +- **`dexOptions` DSL** — removed (d8 handles this automatically) +- **RenderScript** — disabled by default, enable per-module if needed: `buildFeatures { renderScript = true }` +- **AIDL** — disabled by default, enable per-module if needed: `buildFeatures { aidl = true }` + +**Impact:** Low-Medium. Only affects projects using these specific features. + +--- + +## 18. R Class Non-Final in Application Modules + +**Problem:** AGP 9.0 makes R class fields compile-time non-final in application modules (`android.enableAppCompileTimeRClass=true` is now default). Code using `switch` statements on R class fields (like `R.id.some_view`) will fail to compile because `switch` requires compile-time constants. + +**Impact:** Medium. Common in older Java codebases using `switch(view.getId())`. + +**Fix:** Refactor `switch` statements to `if/else`: + +```java +// Old (fails with AGP 9.0) +switch (view.getId()) { + case R.id.button1: // ... + case R.id.button2: // ... +} + +// New +int id = view.getId(); +if (id == R.id.button1) { /* ... */ } +else if (id == R.id.button2) { /* ... */ } +``` + +--- + +## 19. targetSdk Defaults to compileSdk + +**Problem:** AGP 9.0 changes `targetSdk` to default to `compileSdk` when not explicitly set (previously defaulted to `minSdk`). This can silently change app behavior if `targetSdk` was intentionally unset. + +**Impact:** Medium. May trigger new runtime behavior changes associated with higher API levels. + +**Fix:** Explicitly set `targetSdk` in all application modules: + +```kotlin +android { + defaultConfig { + targetSdk = 35 // Set explicitly + } +} +``` + +--- + +## 20. Third-Party Plugin Compatibility + +**Problem:** Many third-party Gradle plugins are incompatible with AGP 9.0 due to removed variant APIs, new DSL interfaces, or built-in Kotlin conflicts. See the main SKILL.md "Plugin Compatibility" section for the full compatibility table. + +**Impact:** High. Can completely block migration. + +**Key plugins requiring opt-out flags:** +- detekt < 2.0.0, ktlint, SQLDelight, Paparazzi, protobuf — see SKILL.md for specific flags + +--- diff --git a/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-APP-SPLIT.md b/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-APP-SPLIT.md new file mode 100644 index 0000000..1129ec5 --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-APP-SPLIT.md @@ -0,0 +1,505 @@ +# Splitting a KMP + Android Application Module for AGP 9.0 + +AGP 9.0 does not support `com.android.application` combined with `org.jetbrains.kotlin.multiplatform` in the same module. You must split the monolithic `composeApp` module into a pure Android application module and a KMP shared library module. + +--- + +## Old Structure (AGP 8.x) + +``` +composeApp/ + build.gradle.kts # com.android.application + kotlin.multiplatform + src/ + commonMain/kotlin/ # Shared KMP code + androidMain/kotlin/ # Android-specific code + MainActivity + androidMain/res/ # Android resources + androidMain/AndroidManifest.xml + iosMain/kotlin/ # iOS-specific code + desktopMain/kotlin/ # Desktop entry point (optional) +``` + +Single `composeApp/build.gradle.kts`: + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + androidTarget { + compilations.all { + kotlinOptions { jvmTarget = "11" } + } + } + iosX64() + iosArm64() + iosSimulatorArm64() + + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(libs.kotlinx.coroutines.core) + } + androidMain.dependencies { + implementation(libs.androidx.activity.compose) + implementation(libs.compose.ui.tooling.preview) + } + } +} + +android { + namespace = "com.example.app" + compileSdk = 34 + defaultConfig { + applicationId = "com.example.app" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + buildFeatures { compose = true } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + debugImplementation(libs.compose.ui.tooling) +} +``` + +--- + +## New Structure (AGP 9.x) + +``` +shared/ + build.gradle.kts # kotlin.multiplatform + com.android.kotlin.multiplatform.library + src/ + commonMain/kotlin/ # All shared KMP code + androidMain/kotlin/ # Android-specific implementations (expect/actual) + androidMain/res/ # Shared Android resources (if any) + iosMain/kotlin/ # iOS-specific code + +androidApp/ + build.gradle.kts # com.android.application ONLY (no kotlin.multiplatform) + src/ + main/kotlin/ # MainActivity, Application class + main/res/ # App-level resources (launcher icons, themes, etc.) + main/AndroidManifest.xml # Full manifest with and + +iosApp/ # Unchanged +``` + +--- + +## androidApp/build.gradle.kts + +**Important:** In AGP 9.0, the `com.android.application` plugin has Kotlin support built in. Do NOT apply `org.jetbrains.kotlin.android` separately -- it will conflict. + +```kotlin +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeCompiler) +} + +android { + namespace = "com.example.app" + compileSdk = 35 + + defaultConfig { + applicationId = "com.example.app" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } +} + +dependencies { + implementation(project(":shared")) + implementation(libs.androidx.activity.compose) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) +} +``` + +### Key Points for androidApp + +- **No `kotlin.multiplatform` plugin.** This is a pure Android application module. +- **No `kotlin-android` plugin.** AGP 9.0's `com.android.application` plugin bundles Kotlin compilation. Applying `org.jetbrains.kotlin.android` will cause a conflict error. +- **`buildTypes` and `productFlavors` work here.** The application plugin still supports full variant configuration. +- **Compose compiler plugin** is applied separately (`composeCompiler`), or it can come from KGP 2.0+ if you use the compose compiler Gradle plugin. +- **Depends on `:shared`** to access all shared KMP code. + +--- + +## shared/build.gradle.kts + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + android { + namespace = "com.example.shared" + compileSdk = 35 + minSdk = 24 + + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + + androidResources { enable = true } + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = "Shared" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(libs.kotlinx.coroutines.core) + } + androidMain.dependencies { + // Android-specific shared dependencies only + } + } +} +``` + +### Key Points for shared + +- **Plugin is `com.android.kotlin.multiplatform.library`**, not `com.android.library`. +- **Namespace must differ from androidApp.** Use `com.example.shared` vs `com.example.app`. +- **No `applicationId`, `versionCode`, `versionName`, `targetSdk`.** These are application-only concepts. +- **No `buildTypes` or `productFlavors`.** The KMP library plugin produces a single variant. +- **Framework exports** (`binaries.framework`) stay here since iOS depends on the shared module. +- **`androidTarget {}`** is replaced with **`android {}`**. + +--- + +## settings.gradle.kts Changes + +### Before + +```kotlin +rootProject.name = "MyProject" +include(":composeApp") +include(":iosApp") // if present as a Gradle module +``` + +### After + +```kotlin +rootProject.name = "MyProject" +include(":shared") +include(":androidApp") +include(":iosApp") +``` + +--- + +## Root build.gradle.kts Changes + +### With Version Catalog + +**Before:** +```kotlin +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false +} +``` + +**After:** +```kotlin +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidKmpLibrary) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false +} +``` + +### Without Version Catalog + +**Before:** +```kotlin +plugins { + id("com.android.application") version "8.7.3" apply false + id("com.android.library") version "8.7.3" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.1.0" apply false + id("org.jetbrains.compose") version "1.7.0" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false +} +``` + +**After:** +```kotlin +plugins { + id("com.android.application") version "9.0.1" apply false + id("com.android.kotlin.multiplatform.library") version "9.0.1" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false + id("org.jetbrains.compose") version "1.10.3" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false +} +``` + +Note: `com.android.library` is replaced with `com.android.kotlin.multiplatform.library`. If you still have pure Android library modules (non-KMP), you can keep `com.android.library` as well. + +--- + +## What to Move to androidApp + +These items are Android application concerns and must move out of the shared KMP module: + +### 1. MainActivity (and any other Activities) + +``` +composeApp/src/androidMain/kotlin/com/example/app/MainActivity.kt + --> androidApp/src/main/kotlin/com/example/app/MainActivity.kt +``` + +Update `MainActivity` to call into shared code: + +```kotlin +// androidApp/src/main/kotlin/com/example/app/MainActivity.kt +package com.example.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.example.shared.App // Import from shared module + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + App() // Shared composable + } + } +} +``` + +### 2. AndroidManifest.xml + +The full application manifest with ``, ``, `` moves to androidApp: + +``` +composeApp/src/androidMain/AndroidManifest.xml + --> androidApp/src/main/AndroidManifest.xml +``` + +**Important:** After moving the manifest, verify the `android:name` attribute on `` points to the correct Activity class in its new location. If the old manifest relied on a default or short class name, you may need to use the fully qualified name: + +```xml + + + + + + +``` + +The shared module may still have a minimal manifest (auto-generated or containing just `` with no ``). + +### 3. Application Class (if any) + +``` +composeApp/src/androidMain/kotlin/.../MyApplication.kt + --> androidApp/src/main/kotlin/.../MyApplication.kt +``` + +### 4. App-Level Resources + +- Launcher icons (`mipmap-*`) +- App theme definitions that reference `applicationId` +- Splash screen resources +- Navigation graphs (if not shared) + +``` +composeApp/src/androidMain/res/mipmap-*/ +composeApp/src/androidMain/res/values/themes.xml (app-level theme) + --> androidApp/src/main/res/ +``` + +### 5. ProGuard Rules + +``` +composeApp/proguard-rules.pro + --> androidApp/proguard-rules.pro +``` + +--- + +## What Stays in shared + +- All `commonMain` code (ViewModels, repositories, models, shared composables) +- All `expect`/`actual` declarations +- All `iosMain`, `desktopMain`, `wasmJsMain` code +- Framework export configuration (`binaries.framework`) +- Shared Android resources (strings, drawables used by shared composables) +- Shared Android-specific implementations (`actual` functions) + +--- + +## Namespace Requirements + +The `namespace` for each module must be unique: + +```kotlin +// shared/build.gradle.kts +kotlin { + android { + namespace = "com.example.shared" + } +} + +// androidApp/build.gradle.kts +android { + namespace = "com.example.app" +} +``` + +If they collide, you will get duplicate R class errors at compile time. The `applicationId` (in androidApp only) can be different from both namespaces. + +--- + +## Run Configuration Updates + +### Android Studio + +After the split, the run configuration for the Android app must point to `:androidApp` instead of `:composeApp`: + +1. Edit Run Configurations +2. Change Module to `androidApp` +3. Ensure the launch activity is `com.example.app.MainActivity` + +### Xcode (iOS) + +If the shared module was renamed from `composeApp` to `shared`: + +1. **Update `baseName` in `shared/build.gradle.kts`** to match the new module name: + ```kotlin + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = "Shared" // was "ComposeApp" + isStatic = true + } + } + ``` + +2. **Update the Run Script build phase** in `project.pbxproj` (or via Xcode > Build Phases > Run Script) to reference the new module: + ```bash + # Old + cd "$SRCROOT/.." + ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode + + # New + cd "$SRCROOT/.." + ./gradlew :shared:embedAndSignAppleFrameworkForXcode + ``` + +3. **Update Swift imports** — in all `.swift` files, change the framework import to match `baseName`: + ```swift + // Old + import ComposeApp + + // New + import Shared + ``` + +4. **Update the app struct name** if it was tied to the old module name. The `@main` struct name in your SwiftUI app entry point is independent of the framework name, but if it referenced the old name, rename it: + ```swift + // Example: rename if it was called ComposeAppApp or similar + @main + struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } + } + ``` + +5. **Update framework search paths** in Xcode Build Settings if they reference the old module directory path. + +--- + +## Quick Checklist + +- [ ] Create `androidApp/` directory with `build.gradle.kts` +- [ ] Move `MainActivity` and `Application` class to `androidApp` +- [ ] Move `AndroidManifest.xml` (full manifest) to `androidApp` +- [ ] Move app-level resources (launcher icons, app theme) to `androidApp` +- [ ] Move ProGuard rules to `androidApp` +- [ ] Convert `composeApp` to `shared` with KMP library plugin +- [ ] Remove application-only config (`applicationId`, `versionCode`, `buildTypes`) from shared +- [ ] Add `implementation(project(":shared"))` to androidApp dependencies +- [ ] Update `settings.gradle.kts` includes +- [ ] Update root `build.gradle.kts` plugin declarations +- [ ] Ensure namespaces are different between modules +- [ ] Update Android Studio run configuration +- [ ] Update Xcode project if iOS target exists +- [ ] Run `./gradlew :androidApp:assembleDebug` and `./gradlew :shared:assemble` to verify diff --git a/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-FULL-RESTRUCTURE.md b/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-FULL-RESTRUCTURE.md new file mode 100644 index 0000000..db5e869 --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-FULL-RESTRUCTURE.md @@ -0,0 +1,478 @@ +# Full Restructure: Extracting All Platform Entry Points + +This guide covers the complete extraction of platform-specific entry points from a monolithic `composeApp` module into dedicated per-platform application modules. This is the most thorough migration path and results in a clean architecture where `shared` contains only cross-platform code. + +--- + +## Target Architecture + +``` +shared/ # KMP library (all shared code) + build.gradle.kts # kotlin.multiplatform + com.android.kotlin.multiplatform.library + src/ + commonMain/kotlin/ # Shared business logic + UI + androidMain/kotlin/ # Android expect/actual implementations + iosMain/kotlin/ # iOS expect/actual implementations + +androidApp/ # Android application entry point + build.gradle.kts # com.android.application + src/main/ + +desktopApp/ # Desktop (JVM) application entry point + build.gradle.kts # org.jetbrains.compose + application {} + src/main/kotlin/ + +webApp/ # Wasm/JS web application entry point + build.gradle.kts # kotlin.multiplatform + wasmJs target + src/wasmJsMain/kotlin/ + +iosApp/ # iOS application (Xcode project, usually already separate) + iosApp.xcodeproj/ +``` + +--- + +## Desktop Extraction + +### Create desktopApp/build.gradle.kts + +```kotlin +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +dependencies { + implementation(project(":shared")) + implementation(compose.desktop.currentOs) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) +} + +compose.desktop { + application { + mainClass = "com.example.app.MainKt" + + nativeDistributions { + targetFormats( + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb + ) + packageName = "com.example.app" + packageVersion = "1.0.0" + + macOS { + iconFile.set(project.file("icons/icon.icns")) + } + windows { + iconFile.set(project.file("icons/icon.ico")) + } + linux { + iconFile.set(project.file("icons/icon.png")) + } + } + } +} +``` + +### Move Desktop Entry Point + +``` +composeApp/src/desktopMain/kotlin/com/example/app/main.kt + --> desktopApp/src/main/kotlin/com/example/app/main.kt +``` + +Update to call shared code: + +```kotlin +// desktopApp/src/main/kotlin/com/example/app/main.kt +package com.example.app + +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import com.example.shared.App + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "My App" + ) { + App() + } +} +``` + +### Remove Desktop from shared + +In `shared/build.gradle.kts`, remove the `jvm("desktop")` target entirely. The desktop target only needs to exist in `desktopApp`. + +**Before (in composeApp):** +```kotlin +kotlin { + jvm("desktop") + // ... + sourceSets { + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + } + } + } +} +compose.desktop { + application { + mainClass = "com.example.app.MainKt" + nativeDistributions { ... } + } +} +``` + +**After (in shared):** +```kotlin +kotlin { + // jvm("desktop") -- REMOVED + // No desktop target in shared module + // No compose.desktop block +} +``` + +If you have shared JVM code that both Android and Desktop use, you have two options: +1. Keep a `jvm()` target in shared (without the `application {}` block) and use intermediate source sets. +2. Put all shared code in `commonMain` and rely on the JVM dependency from `desktopApp`. + +--- + +## Web/WasmJS Extraction + +### Create webApp/build.gradle.kts + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +kotlin { + wasmJs { + browser { + commonWebpackConfig { + outputFileName = "app.js" + } + } + binaries.executable() + } + + sourceSets { + wasmJsMain.dependencies { + implementation(project(":shared")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + } + } +} +``` + +### Move Web Entry Point + +``` +composeApp/src/wasmJsMain/kotlin/com/example/app/main.kt + --> webApp/src/wasmJsMain/kotlin/com/example/app/main.kt +``` + +Update to call shared code: + +```kotlin +// webApp/src/wasmJsMain/kotlin/com/example/app/main.kt +package com.example.app + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.CanvasBasedWindow +import com.example.shared.App + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + CanvasBasedWindow(canvasElementId = "ComposeTarget") { + App() + } +} +``` + +### Move Web Resources + +``` +composeApp/src/wasmJsMain/resources/index.html + --> webApp/src/wasmJsMain/resources/index.html +``` + +Update `index.html` if the output JS filename changed. + +### Remove WasmJS from shared + +In `shared/build.gradle.kts`, remove the `wasmJs {}` target: + +```kotlin +kotlin { + // wasmJs { ... } -- REMOVED +} +``` + +If you need shared Wasm-compatible code, keep `wasmJs()` in shared as a library target (no `binaries.executable()`, no `browser {}` config). + +--- + +## iOS Handling + +iOS is typically already a separate Xcode project. The main considerations during restructure: + +### Framework Export Stays in shared + +```kotlin +// shared/build.gradle.kts +kotlin { + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = "Shared" // Update if renamed from "ComposeApp" + isStatic = true + } + } +} +``` + +### Update Xcode Project + +If the module was renamed from `composeApp` to `shared`: + +1. **Framework import:** Change `import ComposeApp` to `import Shared` in all `.swift` files (must match `baseName` in the framework config). + +2. **Gradle task path:** Update the Run Script build phase in `project.pbxproj` (or via Xcode > Build Phases): + ```bash + # In Xcode Build Phases > Run Script + cd "$SRCROOT/.." + ./gradlew :shared:embedAndSignAppleFrameworkForXcode + ``` + +3. **App struct name:** If the SwiftUI `@main` struct was named after the old module (e.g., `ComposeAppApp`), rename it to something appropriate for your project. + +4. **Framework search paths:** Update Build Settings if they reference the old module directory path. + +5. **Cocoapods (if used):** Update the pod spec name: + ```kotlin + // shared/build.gradle.kts + kotlin { + cocoapods { + name = "Shared" + summary = "Shared KMP module" + // ... + } + } + ``` + +--- + +## Module Rename: composeApp to shared + +### 1. Rename the Directory + +```bash +mv composeApp shared +``` + +### 2. Update settings.gradle.kts + +```kotlin +// Before +include(":composeApp") + +// After +include(":shared") +include(":androidApp") +include(":desktopApp") +include(":webApp") +``` + +### 3. Update Cross-Module Dependencies + +Search all `build.gradle.kts` files for references to `:composeApp`: + +```kotlin +// Before +implementation(project(":composeApp")) + +// After +implementation(project(":shared")) +``` + +### 4. Update .idea / Workspace Files + +If using IntelliJ/Android Studio, the IDE may cache the old module name. Either: +- Delete `.idea/` and re-import +- Or manually update `.idea/modules.xml` and related files + +--- + +## Variant: Native UI (sharedLogic + sharedUI Split) + +For projects where each platform has its own native UI and only business logic is shared: + +``` +sharedLogic/ # Pure KMP library (no Compose) + build.gradle.kts # kotlin.multiplatform + com.android.kotlin.multiplatform.library + src/ + commonMain/kotlin/ # ViewModels, repositories, models, networking + androidMain/kotlin/ # Android-specific implementations + iosMain/kotlin/ # iOS-specific implementations + +sharedUI/ # Optional: Compose Multiplatform UI + build.gradle.kts # kotlin.multiplatform + com.android.kotlin.multiplatform.library + compose + src/ + commonMain/kotlin/ # Shared composables + androidMain/kotlin/ # Android-specific composables + +androidApp/ # Native Android app + build.gradle.kts + src/main/ # Android UI (Compose or XML), depends on sharedLogic (and optionally sharedUI) + +iosApp/ # Native iOS app (SwiftUI/UIKit) + # Depends on sharedLogic framework +``` + +### sharedLogic/build.gradle.kts + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) +} + +kotlin { + android { + namespace = "com.example.shared.logic" + compileSdk = 35 + minSdk = 24 + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = "SharedLogic" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.ktor.client.core) + implementation(libs.kotlinx.serialization.json) + } + } +} +``` + +This variant is useful when: +- iOS uses SwiftUI and does not want Compose Multiplatform +- Desktop is not a target +- You want to minimize the shared surface area + +--- + +## Variant: Server (Backend Module) + +For projects that include a Ktor/Spring server: + +``` +shared/ # KMP library (shared models, API contracts) +androidApp/ +iosApp/ +server/ # JVM server application + build.gradle.kts # kotlin("jvm") + ktor/spring plugin + src/main/kotlin/ +``` + +### server/build.gradle.kts + +```kotlin +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.ktor) // or spring boot + application +} + +application { + mainClass.set("com.example.server.ApplicationKt") +} + +dependencies { + implementation(project(":shared")) + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.netty) + implementation(libs.logback.classic) +} +``` + +The server module is a plain JVM module. It depends on `:shared` for common models and API contracts. It is unaffected by the AGP 9.0 migration except that: +- If shared previously had a `jvm()` target that the server depended on, verify it still exists after restructuring. +- If shared was renamed, update the dependency path. + +--- + +## settings.gradle.kts -- Final State + +```kotlin +rootProject.name = "MyProject" + +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +include(":shared") +include(":androidApp") +include(":desktopApp") +include(":webApp") +// include(":server") // if applicable +``` + +--- + +## Quick Checklist + +- [ ] Create `androidApp/` with pure Android application plugin (see MIGRATION-APP-SPLIT.md) +- [ ] Create `desktopApp/` with compose desktop plugin and `application {}` block +- [ ] Create `webApp/` with wasmJs target and `binaries.executable()` +- [ ] Move `main()` functions from `composeApp/src/{platform}Main/` to respective app modules +- [ ] Move `compose.desktop.application {}` config to `desktopApp` +- [ ] Move `wasmJs { browser {} }` config to `webApp` +- [ ] Rename `composeApp` to `shared` +- [ ] Convert shared to KMP library plugin (`com.android.kotlin.multiplatform.library`) +- [ ] Remove platform app targets from shared (keep only library targets) +- [ ] Update all `settings.gradle.kts` includes +- [ ] Update all `project(":composeApp")` references to `project(":shared")` +- [ ] Update Xcode project (framework name, Gradle task path, Swift imports) +- [ ] Verify each app module builds independently +- [ ] Run all platform targets to confirm functionality diff --git a/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-LIBRARY.md b/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-LIBRARY.md new file mode 100644 index 0000000..0bb4002 --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/references/MIGRATION-LIBRARY.md @@ -0,0 +1,561 @@ +# Migrating a KMP Library Module to AGP 9.0 + +This reference covers the full migration of a Kotlin Multiplatform library module from `com.android.library` (AGP 8.x) to `com.android.kotlin.multiplatform.library` (AGP 9.x). + +--- + +## build.gradle.kts -- Before (AGP 8.x) + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) +} + +kotlin { + androidTarget { + compilations.all { + kotlinOptions { jvmTarget = "11" } + } + } + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + } + androidMain.dependencies { + implementation(libs.androidx.appcompat) + } + } +} + +android { + namespace = "com.example.shared" + compileSdk = 34 + defaultConfig { minSdk = 24 } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + debugImplementation(libs.compose.ui.tooling) +} +``` + +## build.gradle.kts -- After (AGP 9.x) + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) +} + +kotlin { + android { + namespace = "com.example.shared" + compileSdk = 35 + minSdk = 24 + + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + + androidResources { enable = true } + } + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + } + androidMain.dependencies { + implementation(libs.androidx.appcompat) + } + } +} + +dependencies { + androidRuntimeClasspath(libs.compose.ui.tooling) +} +``` + +--- + +## Version and Plugin Changes + +### With Version Catalog (`gradle/libs.versions.toml`) + +**Before:** +```toml +[versions] +agp = "8.7.3" +kotlin = "2.1.0" + +[plugins] +androidLibrary = { id = "com.android.library", version.ref = "agp" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +``` + +**After:** +```toml +[versions] +agp = "9.0.1" +kotlin = "2.3.20" + +[plugins] +androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +``` + +### Without Version Catalog + +If versions are declared directly in build files, update the plugin IDs and versions in place: + +**Before (root build.gradle.kts):** +```kotlin +plugins { + id("com.android.library") version "8.7.3" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.1.0" apply false +} +``` + +**After (root build.gradle.kts):** +```kotlin +plugins { + id("com.android.kotlin.multiplatform.library") version "9.0.1" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false +} +``` + +**Before (module build.gradle.kts):** +```kotlin +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.library") +} +``` + +**After (module build.gradle.kts):** +```kotlin +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") +} +``` + +Key changes: +- The plugin ID changes from `com.android.library` to `com.android.kotlin.multiplatform.library`. +- AGP version must be 9.0.0+, Gradle 9.1.0+, KGP 2.0.0+ (2.3.0+ recommended). + +--- + +## Root build.gradle.kts Changes + +### With Version Catalog + +**Before:** +```kotlin +plugins { + alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.kotlinMultiplatform) apply false +} +``` + +**After:** +```kotlin +plugins { + alias(libs.plugins.androidKmpLibrary) apply false + alias(libs.plugins.kotlinMultiplatform) apply false +} +``` + +### Without Version Catalog + +**Before:** +```kotlin +plugins { + id("com.android.library") version "8.7.3" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.1.0" apply false +} +``` + +**After:** +```kotlin +plugins { + id("com.android.kotlin.multiplatform.library") version "9.0.1" apply false + id("org.jetbrains.kotlin.multiplatform") version "2.3.20" apply false +} +``` + +No other root-level changes are required unless you have convention plugins that reference the old plugin ID (see convention plugin section below). + +--- + +## Source Directory Renames + +The new KMP-integrated plugin does NOT change the expected source directory layout. The standard KMP source sets still apply: + +| Source Set | Directory | +|-------------------------|---------------------------| +| `commonMain` | `src/commonMain/kotlin/` | +| `androidMain` | `src/androidMain/kotlin/` | +| `androidMain` resources | `src/androidMain/res/` | +| `iosMain` | `src/iosMain/kotlin/` | + +**No renames are required** if you already use the standard KMP layout. If your module previously used the classic Android layout (`src/main/java/`, `src/main/res/`), you must migrate to the KMP layout: + +| Old (Android layout) | New (KMP layout) | +|--------------------------------|---------------------------------------| +| `src/main/java/` | `src/androidMain/kotlin/` | +| `src/main/res/` | `src/androidMain/res/` | +| `src/main/AndroidManifest.xml` | `src/androidMain/AndroidManifest.xml` | +| `src/test/java/` | `src/androidHostTest/kotlin/` | +| `src/androidTest/java/` | `src/androidDeviceTest/kotlin/` | + +--- + +## Test Configuration + +The new plugin uses explicit opt-in for test source sets. + +### Host Tests (Unit Tests) + +```kotlin +kotlin { + android { + // Enable unit tests (JVM-based, run on host machine) + withHostTest { + // Optional: configure the host test compilation + } + } +} +``` + +This creates the `androidHostTest` source set. The previous name `androidUnitTest` still works as an alias but `androidHostTest` is preferred. + +### Device Tests (Instrumented Tests) + +```kotlin +kotlin { + android { + // Enable instrumented tests (run on device/emulator) + withDeviceTest { + // Optional: configure the device test compilation + } + } +} +``` + +This creates the `androidDeviceTest` source set. The previous name `androidInstrumentedTest` still works as an alias but `androidDeviceTest` is preferred. + +### Full Test Example + +```kotlin +kotlin { + android { + namespace = "com.example.shared" + compileSdk = 35 + minSdk = 24 + + withHostTest {} + withDeviceTest {} + } + + sourceSets { + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.robolectric) + } + getByName("androidDeviceTest").dependencies { + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.espresso.core) + } + } +} +``` + +--- + +## Java Compilation (withJava) + +If your module contains Java source files in `androidMain`, you must explicitly enable Java compilation: + +```kotlin +kotlin { + android { + withJava() + } +} +``` + +Without this call, `.java` files in `src/androidMain/java/` will be ignored. Kotlin files are compiled by default. + +--- + +## Consumer ProGuard Rules + +### Before (AGP 8.x) + +```kotlin +android { + defaultConfig { + consumerProguardFiles("consumer-rules.pro") + } +} +``` + +### After (AGP 9.x) + +```kotlin +kotlin { + android { + consumerProguardFiles.add(file("consumer-rules.pro")) + } +} +``` + +**Warning:** Consumer ProGuard rules can be silently dropped during migration if you forget this step. The old `android {}` block is gone, so the `consumerProguardFiles` call in `defaultConfig` has no equivalent location unless you explicitly add it in `kotlin { android {} }`. + +--- + +## JVM Target Configuration Hierarchy + +There are three levels at which you can configure the JVM target. They are listed from most specific (highest priority) to least specific (lowest priority): + +### Level 1: Android-Specific Compiler Options (Recommended) + +```kotlin +kotlin { + android { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } +} +``` + +This sets the JVM target only for the Android compilation. + +### Level 2: Top-Level Kotlin Compiler Options + +```kotlin +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } +} +``` + +This sets the JVM target for ALL JVM-based compilations in the project (Android, JVM desktop, etc.). + +### Level 3: Gradle Toolchain + +```kotlin +kotlin { + jvmToolchain(11) +} +``` + +This sets both the JDK used for compilation and the JVM target. It is the broadest setting and affects all JVM compilations. + +### Priority Order + +If multiple levels are set, the most specific wins: +1. `kotlin { android { compilerOptions { } } }` -- highest priority +2. `kotlin { compilerOptions { } }` -- medium priority +3. `kotlin { jvmToolchain() }` -- lowest priority + +### Migration from kotlinOptions + +The old `kotlinOptions` DSL is removed: + +```kotlin +// REMOVED in AGP 9.0 -- do not use +androidTarget { + compilations.all { + kotlinOptions { jvmTarget = "11" } + } +} +``` + +Replace with one of the three levels above. + +--- + +## Dependencies Configuration Changes + +The top-level `dependencies {}` block configurations change because build variants (debug/release) are removed from the KMP library plugin. + +### Before + +```kotlin +dependencies { + debugImplementation(libs.compose.ui.tooling) + releaseImplementation(libs.some.lib) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.test.runner) +} +``` + +### After + +```kotlin +dependencies { + // Use string-based configuration names + "androidRuntimeClasspath"(libs.compose.ui.tooling) + + // Or use sourceSets for most dependencies +} + +kotlin { + sourceSets { + androidMain.dependencies { + implementation(libs.some.lib) + } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + } + getByName("androidDeviceTest").dependencies { + implementation(libs.androidx.test.runner) + } + } +} +``` + +**Prefer putting dependencies inside `sourceSets` blocks** rather than the top-level `dependencies {}` block. The top-level block is only needed for special configurations like `androidRuntimeClasspath` that have no source set equivalent. + +--- + +## Dependency Resolution Details + +When your KMP module depends on a legacy Android library that exposes multiple variants (e.g., `debug`/`release` build types or custom flavor dimensions like `free`/`paid`), you must explicitly define how to resolve them using the `localDependencySelection` DSL. + +### Before + +```kotlin +android { + defaultConfig { + // The consuming module doesn't have a 'tier' dimension, + // so it tells Gradle to use the 'free' flavor of dependencies + missingDimensionStrategy("tier", "free") + } + buildTypes { + getByName("debug") { + // If the dependency doesn't have a 'debug' build type, fallback to 'release' + matchingFallbacks.add("release") + } + } +} +``` + +### After + +```kotlin +kotlin { + android { + localDependencySelection { + // Determine which build type to consume from Android library dependencies, in order of preference + selectBuildTypeFrom.set(listOf("debug", "release")) + + // Map the missing custom flavor dimensions directly + productFlavorDimension("tier") { + selectFrom.set(listOf("free")) + } + } + } +} +``` + +--- + +## Android Resources + +Android resources (`res/`) are not processed by default with the new plugin. You must explicitly enable them: + +```kotlin +kotlin { + android { + androidResources { enable = true } + } +} +``` + +Without this, files in `src/androidMain/res/` will be ignored and `R` class generation will not happen. + +--- + +## Convention Plugin Refactoring + +If you use convention plugins (build-logic), update them: + +### Before + +```kotlin +// build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +class KmpLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("com.android.library") + pluginManager.apply("org.jetbrains.kotlin.multiplatform") + + extensions.configure { + compileSdk = 34 + defaultConfig.minSdk = 24 + } + } + } +} +``` + +### After + +```kotlin +// build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt +class KmpLibraryConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + pluginManager.apply("org.jetbrains.kotlin.multiplatform") + pluginManager.apply("com.android.kotlin.multiplatform.library") + + extensions.configure { + android { + namespace = // set per-module or pass as parameter + compileSdk = 35 + minSdk = 24 + } + } + } + } +} +``` + +The `LibraryExtension` class from AGP is no longer used. All Android configuration goes through `KotlinMultiplatformExtension.android {}`. + +--- + +## Quick Checklist + +- [ ] Update plugin IDs and versions (in `libs.versions.toml` if using version catalog, or directly in build files) +- [ ] Replace plugin alias in `build.gradle.kts` +- [ ] Move `android {}` block contents into `kotlin { android {} }` +- [ ] Replace `androidTarget {}` with `android {}` +- [ ] Replace `kotlinOptions` with `compilerOptions` +- [ ] Enable `androidResources` if using Android resources +- [ ] Enable `withHostTest {}` if there are any android host tests or common tests +- [ ] Enable `withDeviceTest {}` if there are any android device tests +- [ ] Add `withJava()` if module contains Java source files +- [ ] Move consumer ProGuard rules to new DSL +- [ ] Migrate top-level `dependencies` to source set dependencies +- [ ] Update convention plugins if applicable +- [ ] Rename test source dirs: `androidUnitTest` to `androidHostTest`, `androidInstrumentedTest` to `androidDeviceTest` +- [ ] Update root `build.gradle.kts` plugin declarations +- [ ] Run `./gradlew :module:assemble` to verify +- [ ] Run `./gradlew :module:testAndroidHostTest` if there are any android host tests or common tests +- [ ] Run `./gradlew :module:assembleAndroidDeviceTest` if there are any android device tests diff --git a/.agents/skills/kotlin-tooling-agp9-migration/references/PLUGIN-COMPATIBILITY.md b/.agents/skills/kotlin-tooling-agp9-migration/references/PLUGIN-COMPATIBILITY.md new file mode 100644 index 0000000..22ad94b --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/references/PLUGIN-COMPATIBILITY.md @@ -0,0 +1,59 @@ +# Plugin Compatibility: AGP 9.0 + +AGP 9.0 introduces breaking changes that affect many third-party plugins. **Before migrating, check +which plugins the project uses and whether they are compatible.** + +--- + +## Known Compatible Plugins (minimum version required) + +| Plugin | Minimum Compatible Version | Notes | +|-------------------------------------|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `com.google.devtools.ksp` | 2.3.1 (2.3.3+ recommended) | 2.3.1 adds AGP 9.0 support; 2.3.3+ fixes deprecated compilerOptions KGP API usage. May need `android.disallowKotlinSourceSets=false` | +| `com.google.dagger.hilt.android` | 2.59 | — | +| `com.google.firebase.firebase-perf` | 2.0.2 | — | +| `androidx.navigation.safeargs` | 2.9.5 | — | +| `org.jetbrains.compose` | 1.9.3 | — | +| `org.jetbrains.dokka` | 2.2.0-Beta | — | +| `app.cash.burst` | 2.10.0 | — | +| `com.google.firebase.testlab` | 0.0.1-alpha11 | — | + +--- + +## Plugins Requiring Opt-Out Flags + +These work but require temporarily setting `android.newDsl=false` (or other flags): + +| Plugin | Workaround | +|---------------------------------------------------------|-----------------------------------------------------------------------------------------------------| +| `androidx.baselineprofile` (< 1.5.0-alpha01) | `android.newDsl=false` | +| `de.mannodermaus.android-junit5` (< 1.13.4.0) | `android.newDsl=false` | +| `com.google.android.gms:oss-licenses-plugin` (< 0.10.8) | `android.newDsl=false` | +| `com.apollographql.apollo` (< 4.4.0) | `android.newDsl=false` | +| `org.gradle.android.cache-fix` (< 3.0.2) | `android.newDsl=false` | +| `com.github.triplet.play` (< 4.0.0) | `android.newDsl=false` | +| `app.cash.sqldelight` | `android.newDsl=false` + `android.disallowKotlinSourceSets=false` | +| `com.google.protobuf` | `android.newDsl=false` | +| `app.cash.paparazzi` | `android.newDsl=false` | +| `io.gitlab.arturbosch.detekt` (< 2.0.0) | `android.newDsl=false` + `android.builtInKotlin=false` | +| `org.jlleitschuh.gradle.ktlint` | `android.builtInKotlin=false` | +| `dev.icerock.mobile.multiplatform-resources` (< 0.26.0) | `android.builtInKotlin=false` + `android.newDsl=false` + `android.sourceset.disallowProvider=false` | + +--- + +## Known Broken Plugins (No Workaround) + +| Plugin | Status | +|------------------------------|---------------------------| +| `com.newrelic.agent.android` | Incompatible with AGP 9.0 | +| `com.huawei.agconnect.agcp` | Incompatible with AGP 9.0 | + +--- + +## What To Do + +1. **Inventory all plugins** used in the project +2. **Check each against the tables above** +3. **If any plugin is broken without workaround**, inform the user — they may need to wait for a plugin update or remove it +4. **If plugins need opt-out flags**, add them to `gradle.properties` and note them as temporary workarounds +5. **Update plugin versions** to their AGP 9.0-compatible versions before or during migration diff --git a/.agents/skills/kotlin-tooling-agp9-migration/references/VERSION-MATRIX.md b/.agents/skills/kotlin-tooling-agp9-migration/references/VERSION-MATRIX.md new file mode 100644 index 0000000..250dfa8 --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/references/VERSION-MATRIX.md @@ -0,0 +1,132 @@ +# Version Compatibility Matrix for KMP AGP 9.0 Migration + +--- + +## Compatibility Table + +| Component | Minimum | Recommended | Notes | +|-------------------------------|--------------------|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| AGP | 9.0.0 | 9.0.1+ | 9.0.0 is the initial release; 9.0.1+ includes early bug fixes | +| Gradle | 9.1.0 | 9.1.0+ | AGP 9.0 requires Gradle 9.1+; earlier Gradle versions will not work | +| JDK | 17 | 17+ | AGP 9.0 requires JDK 17 minimum | +| SDK Build Tools | 36.0.0 | 36.0.0 | Required by AGP 9.0 | +| KGP (Kotlin Gradle Plugin) | 2.0.0 | 2.3.0+ | 2.0.0 is minimum for KMP library plugin; 2.3.0+ has best compatibility | +| KGP (built-in Kotlin runtime) | 2.2.10 | 2.3.0+ | AGP 9.0 has runtime dependency on KGP 2.2.10; auto-upgrades if lower | +| KSP | 2.3.1 | 2.3.6 | KSP version is no longer tied to the Kotlin compiler version since 2.3.0. AGP 9.0 and built-in Kotlin support added in 2.3.1. KSP migrated away from the deprecated compilerOptions KGP API in 2.3.3; earlier versions may have compatibility problems with other Gradle plugins | +| NDK | — | 28.2.13676358 | Default changed to r28c; specify explicitly if needed | +| Android Studio | Otter 3 (2025.2.3) | Latest stable | First version with full AGP 9.0 + KMP library plugin IDE support | +| IntelliJ IDEA | Not supported | — | Does not support AGP 9.0 as of 2026.1, use Android Studio instead. Can still be used for non-Android KMP targets (JVM, iOS, JS/Wasm) | +| Max API Level | — | 36.1 | Highest supported API level in AGP 9.0 | +| Compose Multiplatform | 1.9.3 | 1.10.0+ | AGP 9.0 support was added in 1.9.3 | +| Compose Compiler Plugin | 2.0.0 | Matches KGP version | Since KGP 2.0, use `org.jetbrains.kotlin.plugin.compose` — version is tied to KGP automatically | +| Kotlin Coroutines | 1.8.0 | 1.10.0+ | 1.8.0+ for full K2 support | +| Kotlin Serialization | 1.6.0 | 1.8.0+ | 1.8.0+ for K2 compiler plugin support | +| Ktor | 2.3.0 | 3.0.0+ | 3.0.0 for best KMP library plugin compatibility | +| Room (KMP) | 2.7.0 | 2.8.0+ | KMP Room requires KSP; verify KSP compatibility | + +--- + + +## Version Notes + +### AGP 9.0.0 + +- First release supporting `com.android.kotlin.multiplatform.library`. +- Built-in Kotlin compilation for `com.android.application` and `com.android.library` (no separate `kotlin-android` plugin needed). +- Removes support for `com.android.application` + `org.jetbrains.kotlin.multiplatform` in the same module. +- Single-variant model for KMP libraries (no build types/flavors). +- Runtime dependency on KGP 2.2.10 — projects using lower KGP versions are auto-upgraded. +- If the project uses KSP, upgrade to 2.3.1+ for AGP 9.0 support. +- New DSL interfaces only — `BaseExtension` and legacy types removed. +- `org.jetbrains.kotlin.kapt` incompatible — use KSP or `com.android.legacy-kapt`. +- Java source/target default changed from Java 8 to Java 11. +- R class is compile-time non-final in application modules by default. +- `targetSdk` defaults to `compileSdk` when not set (was `minSdk`). +- NDK default changed to r28c. +- Requires JDK 17+, Gradle 9.1.0+, SDK Build Tools 36.0.0. +- Many Gradle property defaults changed — see SKILL.md "Gradle Properties Default Changes". +- Removed: embedded Wear OS app support, density split APKs, legacy variant APIs. +- New: IDE support for test fixtures, fused library plugin (preview). + +### AGP 9.0.1+ + +- Bug fixes for KMP library plugin edge cases. +- Improved error messages for common migration mistakes. +- Better IDE sync performance. + +### KGP 2.3.0+ + +- Best compatibility with KMP AGP 9.0 library plugin. +- Improved multiplatform source set inference. +- Better error diagnostics for KMP configuration issues. +- Stable Compose compiler plugin integration. + +--- + +## Upgrade Path + +### From AGP 8.x + KGP 1.9.x + +1. Upgrade KGP to 2.0.0+ first (can be done on AGP 8.x). +2. Migrate `kotlinOptions` to `compilerOptions`. +3. Upgrade Gradle to 9.1.0. +4. Upgrade AGP to 9.0.1+. +5. Migrate library plugins to `com.android.kotlin.multiplatform.library`. +6. Upgrade KGP to 2.3.0+ for best experience. + +### From AGP 8.x + KGP 2.0.x + +1. Upgrade Gradle to 9.1.0. +2. Upgrade AGP to 9.0.1+. +3. Migrate library plugins to `com.android.kotlin.multiplatform.library`. +4. Upgrade KGP to 2.3.0+ for best experience. + +--- + +## gradle/wrapper/gradle-wrapper.properties + +```properties +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1-bin.zip +``` + +--- + +## Basic libs.versions.toml template + +```toml +[versions] +agp = "9.0.1" +kotlin = "2.3.20" +compose-multiplatform = "1.10.3" + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +``` + +--- + +## Compatibility Validation Commands + +Run these to verify your setup is compatible: + +```bash +# Check Gradle version +./gradlew --version + +# Check AGP version applied +./gradlew buildEnvironment | grep -e "com.android.library" -e "com.android.application" -e "com.android.kotlin.multiplatform.library" + +# Check KGP version +./gradlew buildEnvironment | grep "org.jetbrains.kotlin:kotlin-gradle-plugin" + +# Verify the KMP library plugin is recognized +./gradlew :shared:tasks --group=build + +# Full validation build +./gradlew :shared:assemble :androidApp:assembleDebug +``` diff --git a/.agents/skills/kotlin-tooling-agp9-migration/scripts/analyze-project.sh b/.agents/skills/kotlin-tooling-agp9-migration/scripts/analyze-project.sh new file mode 100644 index 0000000..9e991c2 --- /dev/null +++ b/.agents/skills/kotlin-tooling-agp9-migration/scripts/analyze-project.sh @@ -0,0 +1,228 @@ +#!/bin/sh +# +# analyze-project.sh - Analyze a Gradle/KMP project for AGP 9.0 migration readiness +# +# Usage: ./analyze-project.sh [PROJECT_ROOT] +# Defaults to current directory if PROJECT_ROOT is not specified. + +set -e + +PROJECT_ROOT="${1:-.}" + +# Resolve to absolute path +PROJECT_ROOT="$(cd "$PROJECT_ROOT" && pwd)" + +echo "========================================" +echo " KMP AGP 9.0 Migration - Project Analysis" +echo "========================================" +echo "" +echo "Project root: $PROJECT_ROOT" +echo "" + +# --- Gradle Version --- +echo "----------------------------------------" +echo " Gradle Version" +echo "----------------------------------------" +WRAPPER_PROPS="$PROJECT_ROOT/gradle/wrapper/gradle-wrapper.properties" +if [ -f "$WRAPPER_PROPS" ]; then + GRADLE_URL=$(grep 'distributionUrl' "$WRAPPER_PROPS" | sed 's/.*=//' | sed 's/\\//g') + GRADLE_VERSION=$(echo "$GRADLE_URL" | sed 's|.*gradle-||' | sed 's|-.*||') + echo " Distribution URL: $GRADLE_URL" + echo " Gradle version: $GRADLE_VERSION" +else + echo " WARNING: gradle-wrapper.properties not found" + GRADLE_VERSION="unknown" +fi +echo "" + +# --- AGP Version --- +echo "----------------------------------------" +echo " Android Gradle Plugin Version" +echo "----------------------------------------" +TOML_FILE="$PROJECT_ROOT/gradle/libs.versions.toml" +if [ -f "$TOML_FILE" ]; then + AGP_VERSION=$(grep '^agp' "$TOML_FILE" | head -1 | sed 's/.*= *"//' | sed 's/".*//') + if [ -n "$AGP_VERSION" ]; then + echo " AGP version (from version catalog): $AGP_VERSION" + else + echo " AGP version not found in version catalog" + AGP_VERSION="unknown" + fi +else + echo " WARNING: libs.versions.toml not found" + AGP_VERSION="unknown" +fi + +KOTLIN_VERSION=$(grep '^kotlin' "$TOML_FILE" 2>/dev/null | head -1 | sed 's/.*= *"//' | sed 's/".*//') +if [ -n "$KOTLIN_VERSION" ]; then + echo " Kotlin version: $KOTLIN_VERSION" +fi +echo "" + +# --- Module Analysis --- +echo "----------------------------------------" +echo " Module Analysis" +echo "----------------------------------------" + +# Find all build.gradle.kts and build.gradle files +BUILD_FILES=$(find "$PROJECT_ROOT" -name "build.gradle.kts" -o -name "build.gradle" | grep -v '.gradle/' | grep -v 'build/' | sort) + +for BUILD_FILE in $BUILD_FILES; do + REL_PATH=$(echo "$BUILD_FILE" | sed "s|$PROJECT_ROOT/||") + MODULE_DIR=$(dirname "$BUILD_FILE") + REL_MODULE=$(echo "$MODULE_DIR" | sed "s|$PROJECT_ROOT||" | sed 's|^/||') + + if [ -z "$REL_MODULE" ]; then + MODULE_NAME="(root)" + else + MODULE_NAME=":$(echo "$REL_MODULE" | sed 's|/|:|g')" + fi + + echo "" + echo " Module: $MODULE_NAME" + echo " File: $REL_PATH" + + # Detect plugins + HAS_ANDROID_APP="no" + HAS_ANDROID_LIB="no" + HAS_KMP="no" + HAS_KOTLIN_ANDROID="no" + HAS_COMPOSE="no" + HAS_APPLY_FALSE="no" + + if grep -q 'com.android.application\|androidApplication' "$BUILD_FILE"; then + if grep -q 'apply false' "$BUILD_FILE" 2>/dev/null; then + HAS_APPLY_FALSE="yes" + else + HAS_ANDROID_APP="yes" + fi + fi + + if grep -q 'com.android.library\|androidLibrary' "$BUILD_FILE"; then + if grep -q 'apply false' "$BUILD_FILE" 2>/dev/null; then + HAS_APPLY_FALSE="yes" + else + HAS_ANDROID_LIB="yes" + fi + fi + + if grep -q 'kotlin.multiplatform\|kotlin("multiplatform")\|kotlinMultiplatform' "$BUILD_FILE"; then + HAS_KMP="yes" + fi + + if grep -q 'kotlin.android\|kotlin("android")\|kotlinAndroid' "$BUILD_FILE"; then + HAS_KOTLIN_ANDROID="yes" + fi + + if grep -q 'org.jetbrains.compose\|composeMultiplatform' "$BUILD_FILE"; then + HAS_COMPOSE="yes" + fi + + echo " Plugins detected:" + [ "$HAS_ANDROID_APP" = "yes" ] && echo " - com.android.application" + [ "$HAS_ANDROID_LIB" = "yes" ] && echo " - com.android.library" + [ "$HAS_KMP" = "yes" ] && echo " - kotlin.multiplatform" + [ "$HAS_KOTLIN_ANDROID" = "yes" ] && echo " - kotlin.android" + [ "$HAS_COMPOSE" = "yes" ] && echo " - org.jetbrains.compose" + [ "$HAS_APPLY_FALSE" = "yes" ] && echo " - (declarations with apply false — root buildscript)" + + # Check for android {} block + HAS_ANDROID_BLOCK="no" + if grep -q '^android {' "$BUILD_FILE" || grep -q '^android {' "$BUILD_FILE"; then + HAS_ANDROID_BLOCK="yes" + echo " Has android {} block: yes" + fi + + # Check source set layout + if [ -d "$MODULE_DIR/src/main" ]; then + echo " Source layout: src/main (legacy Android)" + fi + if [ -d "$MODULE_DIR/src/androidMain" ]; then + echo " Source layout: src/androidMain (KMP)" + fi + if [ -d "$MODULE_DIR/src/commonMain" ]; then + echo " Source layout: src/commonMain (KMP)" + fi + + # Determine migration recommendation + echo " Migration recommendation:" + if [ "$HAS_APPLY_FALSE" = "yes" ]; then + echo " -> Root buildscript: update plugin versions only" + elif [ "$HAS_KMP" = "yes" ] && [ "$HAS_ANDROID_LIB" = "yes" ]; then + echo " -> Replace com.android.library with android-kotlin-multiplatform-library" + echo " -> Move android {} config into androidTarget {} in kotlin {} block" + echo " -> Remove the standalone android {} block" + elif [ "$HAS_KMP" = "yes" ] && [ "$HAS_ANDROID_APP" = "yes" ]; then + echo " -> Split into separate androidApp module (com.android.application)" + echo " -> Convert shared KMP module to use android-kotlin-multiplatform-library" + echo " -> Move Android entry point (Activity) to the new androidApp module" + elif [ "$HAS_ANDROID_APP" = "yes" ] && [ "$HAS_KMP" = "no" ]; then + echo " -> Pure Android app module: update AGP to 9.x, no KMP migration needed" + elif [ "$HAS_ANDROID_LIB" = "yes" ] && [ "$HAS_KMP" = "no" ]; then + echo " -> Pure Android library: update AGP to 9.x, no KMP migration needed" + echo " -> (Consider converting to KMP if cross-platform is desired)" + elif [ "$HAS_KOTLIN_ANDROID" = "yes" ]; then + echo " -> Replace org.jetbrains.kotlin.android with kotlin.multiplatform if going KMP" + echo " -> Or keep as-is and just update AGP version" + else + echo " -> No Android plugins detected: no AGP migration needed" + fi +done + +echo "" + +# --- Gradle Properties Check --- +echo "----------------------------------------" +echo " Gradle Properties" +echo "----------------------------------------" +GRADLE_PROPS="$PROJECT_ROOT/gradle.properties" +if [ -f "$GRADLE_PROPS" ]; then + LEGACY_FLAGS="" + if grep -q 'android.enableLegacyVariantApi' "$GRADLE_PROPS"; then + LEGACY_FLAGS="$LEGACY_FLAGS\n - android.enableLegacyVariantApi (must be removed for AGP 9.0)" + fi + if grep -q 'android.useAndroidX' "$GRADLE_PROPS"; then + LEGACY_FLAGS="$LEGACY_FLAGS\n - android.useAndroidX (default in AGP 9.0, can be removed)" + fi + if grep -q 'android.enableJetifier' "$GRADLE_PROPS"; then + LEGACY_FLAGS="$LEGACY_FLAGS\n - android.enableJetifier (removed in AGP 9.0, must be removed)" + fi + if grep -q 'android.nonTransitiveRClass' "$GRADLE_PROPS"; then + LEGACY_FLAGS="$LEGACY_FLAGS\n - android.nonTransitiveRClass (default in AGP 9.0, can be removed)" + fi + + if [ -n "$LEGACY_FLAGS" ]; then + echo " Legacy flags found:" + printf "$LEGACY_FLAGS\n" + else + echo " No legacy flags found" + fi +else + echo " No gradle.properties file found" +fi +echo "" + +# --- Summary --- +echo "========================================" +echo " Summary" +echo "========================================" +echo "" +echo " Current AGP version: $AGP_VERSION" +echo " Current Gradle version: $GRADLE_VERSION" +echo " Target AGP version: 9.0.0+" +echo " Target Gradle version: 9.1.0+" +echo "" + +if [ "$AGP_VERSION" != "unknown" ]; then + AGP_MAJOR=$(echo "$AGP_VERSION" | cut -d. -f1) + if [ "$AGP_MAJOR" -ge 9 ] 2>/dev/null; then + echo " Status: Project appears to already be on AGP 9.0+" + else + echo " Status: Project needs migration from AGP $AGP_VERSION to 9.0+" + fi +fi + +echo "" +echo "========================================" +echo " Run the migration skill for guided assistance." +echo "========================================" diff --git a/.agents/skills/kotlin-tooling-cocoapods-spm-migration/SKILL.md b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/SKILL.md new file mode 100644 index 0000000..0fddeb1 --- /dev/null +++ b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/SKILL.md @@ -0,0 +1,523 @@ +--- +name: kotlin-tooling-cocoapods-spm-migration +description: Migrate KMP projects from CocoaPods (kotlin("native.cocoapods")) to Swift Package Manager (swiftPMDependencies DSL) — replaces pod() with swiftPackage(), transforms cocoapods.* imports to swiftPMImport.*, and reconfigures the Xcode project. +license: Apache-2.0 +metadata: + author: JetBrains + version: "1.0.0" +--- + +# CocoaPods to SwiftPM Migration for KMP + +Migrate Kotlin Multiplatform projects from `kotlin("native.cocoapods")` to `swiftPMDependencies {}` DSL. + +## Requirements + +- **Kotlin**: 2.4.0-Beta2 or later (first public release with `swiftPMDependencies` support, available on Maven Central) +- **Xcode**: 16.4 or 26.0+ +- **iOS Deployment Target**: 16.0+ recommended + +## Migration Overview + +**IMPORTANT**: Keep the `cocoapods {}` block and plugin active until Phase 6. The migration adds `swiftPMDependencies {}` alongside the existing CocoaPods setup first, reconfigures Xcode, and only then removes CocoaPods. + +| Phase | Action | +|-------|--------| +| 1 | Analyze existing CocoaPods configuration | +| 2 | Update Gradle configuration (repos, Kotlin version) | +| 3 | Add `swiftPMDependencies {}` alongside existing `cocoapods {}` | +| 4 | Transform Kotlin imports | +| 5 | Reconfigure iOS project and deintegrate CocoaPods | +| 6 | Remove CocoaPods plugin from Gradle | +| 7 | Verify Gradle build and Xcode project build | +| 8 | Write MIGRATION_REPORT.md | + +--- + +## Phase 1: Pre-Migration Analysis + +### 1.0 Verify the project builds + +Before starting migration, identify the module to migrate and confirm it compiles successfully. + +1. **Find the module that uses CocoaPods** — look for `build.gradle.kts` files containing `cocoapods`: + ```bash + grep -rl "cocoapods" --include="build.gradle.kts" . + ``` + Extract the module name from the path (e.g., `./shared/build.gradle.kts` → module name is `shared`). Note: multiple modules may use CocoaPods — record all of them. Typically only the module that produces the framework linked into the iOS app needs `swiftPMDependencies`; the others only need CocoaPods removed (Phase 6). + +2. **Compile Kotlin code** — run the Kotlin compilation task for that module to verify the Kotlin source compiles: + ```bash + ./gradlew :moduleName:compileKotlinIosSimulatorArm64 + ``` + Replace `moduleName` with the directory name of the module (e.g., `:shared:compileKotlinIosSimulatorArm64`). This is faster than a full `build` (which also runs release linkage) and sufficient to verify Kotlin code correctness. + +3. **Build the iOS app (optional)** — try to locate the Xcode project and build it to confirm the full app compiles: + ```bash + # Find the Xcode project + find . -name "*.xcworkspace" -not -path "*/Pods/*" -maxdepth 2 + # Build (replace scheme name with the actual app scheme) + cd /path/to/iosApp + xcodebuild -workspace *.xcworkspace -scheme "" -destination 'generic/platform=iOS Simulator' ARCHS=arm64 + ``` + If the user wants to skip the Xcode build or no Xcode project is found, proceed without it — the Kotlin compilation from step 2 is sufficient to continue. + +4. **If the Kotlin compilation fails**, ask the user to either: + - Provide the correct Gradle command to verify the module builds, or + - Confirm the module is in a working state and it's safe to proceed + + If the user confirms without providing a build command, **record that the pre-migration build could not be verified** and warn about this at the end of migration (Phase 7). + +### 1.0a Confirm Kotlin version with Swift Import support + +Read the current Kotlin version from `gradle/libs.versions.toml` (or `build.gradle.kts`). + +**If the project already uses Kotlin 2.4.0-Beta2 or later** → record the version and skip Phase 2.1 (no version change needed). + +**If the project uses an older Kotlin version** → Phase 2.1 will upgrade it to `2.4.0-Beta2` (the first public release with `swiftPMDependencies` support, available on Maven Central — no custom repository needed). Warn the user: "⚠️ Kotlin version jump — upgrading across minor versions can introduce breaking changes unrelated to this migration. Recommended: update first, verify it builds, then re-run this migration." If the user confirms, proceed. + +### 1.1 Check for deprecated CocoaPods workaround property + +Search `gradle.properties` for the deprecated property: + +```properties +kotlin.apple.deprecated.allowUsingEmbedAndSignWithCocoaPodsDependencies=true +``` + +This property was a workaround (see [KT-64096](https://youtrack.jetbrains.com/issue/KT-64096)) for projects using `embedAndSign` alongside CocoaPods dependencies. It suppresses an error about unsupported configurations that can cause runtime crashes or symbol duplication. After migrating to SwiftPM import, this property is no longer needed and **must be removed** in Phase 6. Record its presence if found. + +### 1.2 Check for EmbedAndSign disablers + +Search all `build.gradle.kts` files for code that disables `EmbedAndSign` tasks (e.g., `TaskGraph.whenReady` filters, `tasks.matching` blocks). This is a CocoaPods-era workaround that **breaks the migration** because `integrateEmbedAndSign` (needed in Phase 5) gets disabled too. Record any such code — it **must be removed** in Phase 6, and may need to be removed earlier. See [troubleshooting.md](references/troubleshooting.md) § "`integrateEmbedAndSign` Skipped" for patterns. + +### 1.3 Check for third-party KMP libraries with bundled cinterop klibs + +Some KMP libraries ship pre-built cinterop klibs with `cocoapods.*` package namespaces. After migration, the swiftPMDependencies cinterop generator detects these existing bindings and **skips generating new bindings** for those Clang modules to avoid duplicates. This means `cocoapods.*` imports for those modules must be **kept as-is** — they resolve to the third-party library's bundled klib, not to actual CocoaPods. + +**Known libraries with bundled `cocoapods.*` klibs:** + +| Library | Maven artifact | Bundled klib namespace | Classes provided | +|---------|---------------|----------------------|-----------------| +| [KMPNotifier](https://github.com/mirzemehdi/KMPNotifier) | `io.github.mirzemehdi:kmpnotifier` | `cocoapods.FirebaseMessaging` | `FIRMessaging`, `FIRMessagingAPNSTokenType`, etc. | + +**How to detect:** Search Gradle dependency declarations for known libraries, then cross-reference their bundled namespaces against the `import cocoapods.*` statements found in step 4. Mark any matches — these imports will NOT be transformed in Phase 4. + +If unsure whether a third-party KMP library bundles cinterop klibs, check if it has a `linkOnly = true` pod dependency in the project — this is a strong indicator that the library provides its own klib for those classes. + +To inspect klib contents and verify bundled bindings, see [troubleshooting.md](references/troubleshooting.md) § "Third-Party KMP Libraries with Bundled Klibs". + +**Find and record:** + +1. **CocoaPods configuration** - Search for `cocoapods` in `build.gradle.kts` files +2. **Pod dependencies** - Extract pod names, versions from `cocoapods {}` blocks +3. **Framework configuration** - Record `baseName`, `isStatic`, deployment target from `cocoapods.framework {}` +4. **linkOnly pods** - Record pods declared with `linkOnly = true`. These have two common patterns: + - **KMP wrapper libraries** (e.g., `dev.gitlive:firebase-*`): the wrapper provides Kotlin APIs, and the pod is only linked. See [common-pods-mapping.md](references/common-pods-mapping.md) for implications. + - **Multi-module projects**: the consuming module declares `linkOnly = true` because a child module already provides cinterop bindings for that pod. In SwiftPM, the `swiftPackage()` declaration should go **only** in the child module that uses the pod directly. The consuming module must NOT redeclare the same packages — it only needs a `swiftPMDependencies {}` block without those packages (or an empty one if all pods were `linkOnly`). **Import namespace implication:** when the consuming module imports SPM classes that come from a child module's `swiftPMDependencies`, the import path uses the **child module's** group and name as the namespace (see Phase 4 Import Namespace Formula). +5. **Kotlin imports** - Find all `import cocoapods.*` statements. Cross-reference with step 1.3 to identify which imports come from bundled klibs (and must be preserved) vs. which come from direct pod cinterop (and must be transformed). +6. **Map pods to SPM** - See [common-pods-mapping.md](references/common-pods-mapping.md) +7. **Locate iOS project directory** - Find the directory containing `Podfile` and `.xcworkspace`: + ```bash + find . -name "Podfile" -type f + ``` + Record this path (e.g., `iosApp/`, `ios/`, or project root) - needed for Phase 5 +8. **Check for non-KMP CocoaPods** - Determine if the project uses CocoaPods for dependencies other than KMP. This affects cleanup strategy in Phase 5. +9. **Cross-reference Podfile against `cocoapods {}` block** - Parse the `Podfile` and compare its pod entries with the pods declared in the Gradle `cocoapods {}` block. Record any dependencies that exist in the `Podfile` but are **not** listed in `cocoapods {}`. These Podfile-only dependencies still linked into the app via CocoaPods and must be migrated to `swiftPMDependencies` — dropping them silently causes obscure linkage errors at runtime. +10. **Check Xcode build phases** - Open the `.xcodeproj`'s `project.pbxproj` and search for the Gradle build phase script. Check if `embedAndSignAppleFrameworkForXcode` is present but **commented out** (prefixed with `#`). If commented out, it must be uncommented during Phase 5 — the `integrateEmbedAndSign` task may or may not handle this automatically. +11. **Check for existing Crashlytics dSYM upload script** - If using FirebaseCrashlytics, search `project.pbxproj` for a dSYM upload shell script phase. Record its current path (CocoaPods-era scripts reference `${PODS_ROOT}/FirebaseCrashlytics/upload-symbols`). This must be updated to the SPM path in Phase 5. +12. **Identify CocoaPods-related extras in build scripts** - Search all `build.gradle.kts` files for CocoaPods workarounds beyond the standard `cocoapods {}` block (custom tasks hooking into `podInstall`, `Pods.xcodeproj` patching, podspec metadata, `extraSpecAttributes`, `noPodspec()`, etc.). See [cocoapods-extras-patterns.md](references/cocoapods-extras-patterns.md) for the full pattern list. Record all findings — these will be handled in Phase 6. + +--- + +## Phase 2: Gradle Configuration + +**Important scope note:** Do NOT upgrade the Gradle wrapper version, update KSP, or update any other dependencies during this migration. Those are separate concerns and out of scope. Only change what is listed below. + +### 2.1 Update Kotlin version + +**Skip this step** if the project already uses Kotlin 2.4.0-Beta2 or later (recorded in Phase 1.0a). + +Update to `2.4.0-Beta2` (or the latest available release with Swift Import support) in `gradle/libs.versions.toml`: + +```toml +[versions] +kotlin = "2.4.0-Beta2" +``` + +`2.4.0-Beta2` is available on Maven Central — no custom repository is needed. + +--- + +## Phase 3: Add swiftPMDependencies (Keep CocoaPods) + +**Do NOT remove the `cocoapods {}` block or `kotlin("native.cocoapods")` plugin yet.** Add `swiftPMDependencies {}` alongside the existing CocoaPods configuration. + +### 3.1 Add group property + +```kotlin +group = "org.example.myproject" // Required for import namespace +``` + +**Compose Resources warning:** If the project uses Compose Multiplatform resources (`org.jetbrains.compose` plugin or `compose.resources`), the `group` property is also used as the namespace for generated resource accessors (e.g., `Res.string.*`, `Res.drawable.*`). If `group` already exists in `build.gradle.kts`, do **not** change it. If you are adding `group` for the first time, warn the user that existing Compose resource accessor call sites throughout the project will change namespace and may need updating. + +### 3.2 Add swiftPMDependencies block alongside cocoapods + +For each pod dependency, add the equivalent SwiftPM package declaration. Use [common-pods-mapping.md](references/common-pods-mapping.md) to map each pod to its SPM package URL, product name, and `importedClangModules`. + +**Version preservation:** Do NOT bump dependency versions during migration. Use the exact same version that was specified in the `cocoapods {}` block. Changing versions can resolve to different library builds that break cinterop APIs (removed symbols, changed signatures) and introduce issues unrelated to the migration itself. + +| CocoaPods version spec | SPM equivalent | Example | +|------------------------|---------------|---------| +| `version = "1.2.3"` (exact) | `version = "1.2.3"` (simple) or `exact("1.2.3")` (typed) | `pod("GoogleMaps") { version = "10.3.0" }` → `version = "10.3.0"` | +| `version = "~> 1.2"` (optimistic) | `version = "1.2.0"` (simple) or `from("1.2.0")` (typed) | `pod("FirebaseAuth") { version = "~> 12.5" }` → `version = "12.5.0"` | +| No version specified | Ask user which version to pin | Ask the user which version to use | + +**Two API forms:** The DSL has a simple string API and a typed API. **Use the simple string API** for most packages: +```kotlin +swiftPackage(url = "https://github.com/owner/repo.git", version = "1.0.0", products = listOf("ProductName")) +``` +The simple API auto-defaults `importedClangModules` to the `products` list. Use the typed API (with `url()`, `exact()`, `product()` wrappers) only when you need exact version pinning, platform constraints, or explicit Clang module control. See [dsl-reference.md](references/dsl-reference.md) for the typed API. + +**Key concepts:** `products` = SPM product names (controls linking). `importedClangModules` = Clang module names for cinterop bindings (only when `discoverClangModulesImplicitly = false`). `discoverClangModulesImplicitly` defaults to `true` (bindings for all Clang modules); set `false` when transitive C/C++ modules fail cinterop (Firebase, gRPC), then list needed modules explicitly. + +**Important:** SPM product names and Clang module names don't always match. Always consult [common-pods-mapping.md](references/common-pods-mapping.md) for correct values. + +**Podfile-only dependencies:** If Phase 1 step 9 identified dependencies that exist in the `Podfile` but not in the Gradle `cocoapods {}` block, these must also be added to `swiftPMDependencies` as `products` entries. Even though the KMP module didn't declare them, they were linked into the app by CocoaPods and may be required for the app to build. Look up each Podfile-only pod's SPM package URL and add it as a `swiftPackage()` with at least its `products`. If any of these pods were used via cinterop (check for `import cocoapods.*` statements referencing them), also add `importedClangModules`. + +**Do not mix the same library suite across CocoaPods and SPM.** Libraries that share a common repository (e.g., all Firebase products) share transitive dependencies. Having some products linked via CocoaPods and others via SPM causes duplicate/conflicting symbols and dyld crashes at runtime. When migrating such a suite, move **all** pods from that suite to SPM at once — including Swift-only pods that Kotlin doesn't use directly. Add Swift-only pods as `products` entries (no `importedClangModules` needed). After adding new products, re-run `integrateLinkagePackage` to regenerate the linkage Swift package. + +```kotlin +kotlin { + // Keep existing targets + iosArm64() + iosSimulatorArm64() + iosX64() + + swiftPMDependencies { + iosMinimumDeploymentTarget = "16.0" + + swiftPackage( + url = "https://github.com/owner/repo.git", + version = "1.0.0", + products = listOf("ProductName"), + ) + } + + cocoapods { + // ... keep existing cocoapods block for now + } +} +``` + +### 3.3 Move framework configuration out of cocoapods block + +If the `cocoapods` block contains a `framework {}` configuration, move it to the `binaries` API on each target. **`isStatic = true` is recommended** — dynamic frameworks have known edge cases with SwiftPM import that can cause linker errors, dyld crashes, or duplicate class warnings: + +```kotlin +listOf(iosArm64(), iosSimulatorArm64(), iosX64()).forEach { iosTarget -> + iosTarget.binaries.framework { baseName = "Shared"; isStatic = true } +} +``` + +If the `cocoapods.framework {}` block contained `export(project(...))` or `transitiveExport = true`, preserve these in the new `binaries.framework {}` block — they are essential for multi-module projects where the framework exports child modules. + +### 3.4 Handle dev.gitlive/firebase-kotlin-sdk and similar CocoaPods-era KMP wrappers + +If the project uses `dev.gitlive:firebase-*` or similar KMP wrapper libraries, two additional steps are required: + +**A. Switch to `isStatic = true`** — dynamic frameworks + Firebase SPM = runtime `dyld` crash. After switching: re-run `integrateLinkagePackage`, remove any "Embed Frameworks" copy phase, move linker flags to `OTHER_LDFLAGS`. + +**B. Add framework search paths** — add conditional `-F` linkerOpts in `build.gradle.kts` and matching `FRAMEWORK_SEARCH_PATHS` in the Xcode project. + +See [common-pods-mapping.md](references/common-pods-mapping.md) § dev.gitlive and [troubleshooting.md](references/troubleshooting.md) for code snippets and the full product list. + +### 3.5 Add opt-in annotations + +The `swiftPackage()` and `localSwiftPackage()` DSL functions are annotated with `@ExperimentalKotlinGradlePluginApi`. Add this opt-in to suppress the compiler warning: + +```kotlin +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +``` + +Place this at the top of each `build.gradle.kts` file that calls `swiftPackage()` or `localSwiftPackage()`. + +Also add the cinterop opt-in for Kotlin source files: + +```kotlin +kotlin.compilerOptions { + optIn.add("kotlinx.cinterop.ExperimentalForeignApi") +} +``` + +For full DSL reference, see [dsl-reference.md](references/dsl-reference.md). + +--- + +## Phase 4: Kotlin Source Updates + +### Import Namespace Formula + +``` +swiftPMImport... + +Where: +- group: build.gradle.kts `group` property of the MODULE THAT DECLARES the swiftPMDependencies, dashes (-) → dots (.) +- module: Gradle module name of the MODULE THAT DECLARES the swiftPMDependencies, dashes (-) → dots (.), underscores (_) preserved as-is +- ClassName: Objective-C class name (FIR* for Firebase, GMS* for Google Maps) +``` + +**The namespace uses the declaring module's group+name, not the importing module's.** This is the most common mistake agents make. When module A depends on module B, and module B declares `swiftPMDependencies`, module A imports SPM classes using module B's group and module name — NOT module A's. + +### Example Transformation — Single Module + +```kotlin +// group = "org.jetbrains.kotlin.firebase.sample", module = "kotlin-library" + +// BEFORE: +import cocoapods.FirebaseAnalytics.FIRAnalytics + +// AFTER: +import swiftPMImport.org.jetbrains.kotlin.firebase.sample.kotlin.library.FIRAnalytics +``` + +### Example Transformation — Multi-Module (linkOnly pods) + +When a consuming module had `pod("GoogleMaps") { linkOnly = true }` because a child module provides the cinterop bindings: + +```kotlin +// composeApp/App.kt — composeApp depends on :google-maps Gradle module +// google-maps has group = "org.jetbrains.kotlin.google-maps", module name = "google-maps" +// google-maps declares swiftPMDependencies with GoogleMaps + +// BEFORE (in composeApp): +import cocoapods.GoogleMaps.GMSServices + +// AFTER — uses google-maps module's namespace, NOT composeApp's namespace: +import swiftPMImport.org.jetbrains.kotlin.google.maps.google.maps.GMSServices +// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^ +// google-maps's group (dashes→dots) google-maps's module name (dashes→dots) +``` + +The import path does NOT use composeApp's group (`org.jetbrains.kotlin.compose.sample`). It uses the declaring module's identity because that's where the cinterop bindings are generated. + +**Import flattening:** The Clang module name (e.g., `FirebaseFirestoreInternal`, `FirebaseAuth`) disappears from the import path — all classes are flattened under the same `swiftPMImport..` prefix regardless of which library they come from. For example, both `cocoapods.FirebaseAuth.FIRAuth` and `cocoapods.FirebaseFirestoreInternal.FIRFirestore` become `swiftPMImport...FIRAuth` and `swiftPMImport...FIRFirestore`. + +### Preserving Bundled Klib Imports + +> **CRITICAL:** Do NOT replace `cocoapods.*` imports that resolve to third-party KMP libraries' bundled cinterop klibs (identified in Phase 1 step 1.3). These imports must remain as-is — the `cocoapods` prefix is the package namespace in the library's published klib, not an actual CocoaPods dependency. The swiftPMDependencies cinterop generator skips modules already provided by a dependency's klib, so `swiftPMImport.*` for those classes will fail with "Unresolved reference". + +**Example** (project using [KMPNotifier](https://github.com/mirzemehdi/KMPNotifier)): +```kotlin +// KEEP — resolves to kmpnotifier's bundled cinterop klib +import cocoapods.FirebaseMessaging.FIRMessaging +``` + +### Bulk Replacement + +Use a regex find-and-replace across all Kotlin source files, **excluding imports identified in Phase 1 step 1.3**. In multi-module projects, run the replacement separately for each module using that module's group and name: + +``` +Find: cocoapods\.\w+\. +Replace: swiftPMImport... +``` + +After bulk replacement, **manually restore** any `cocoapods.*` imports that should be preserved (from bundled klibs). + +**Finding correct import path:** Run `./gradlew :moduleName:compileKotlinIosSimulatorArm64` - errors show available classes. + +--- + +## Phase 5: iOS Project Reconfiguration + +### 5.1 Get migration command + +Build the CocoaPods workspace to obtain the migration command: + +```bash +cd /path/to/iosApp + +xcodebuild -scheme "$(echo -n *.xcworkspace | python3 -c 'import sys, json; from subprocess import check_output; print(list(set(json.loads(check_output(["xcodebuild", "-workspace", sys.stdin.readline(), "-list", "-json"]))["workspace"]["schemes"]) - set(json.loads(check_output(["xcodebuild", "-project", "Pods/Pods.xcodeproj", "-list", "-json"]))["project"]["schemes"]))[0])')" -workspace *.xcworkspace -destination 'generic/platform=iOS Simulator' ARCHS=arm64 | grep -A5 'What went wrong' +``` + +The build output will contain a command like: +```bash +XCODEPROJ_PATH='/path/to/project/iosApp.xcodeproj' GRADLE_PROJECT_PATH=':shared' '/path/to/project/gradlew' -p '/path/to/project' ':shared:integrateEmbedAndSign' ':shared:integrateLinkagePackage' +``` + +Run this command. It modifies the `.xcodeproj` to trigger `embedAndSignAppleFrameworkForXcode` during the build. `integrateLinkagePackage` is a one-time setup — it does not need to be added as a build phase. If `integrateEmbedAndSign` is skipped, check for EmbedAndSign disablers (Phase 1 step 1.2) — remove them first, then re-run. + +**Verify `embedAndSignAppleFrameworkForXcode` is active:** After running integration, check the build phase script in `project.pbxproj`. If `embedAndSignAppleFrameworkForXcode` is commented out (prefixed with `#`), uncomment it. + +The `integrateLinkagePackage` task generates `KotlinMultiplatformLinkedPackage/` at `/` — a local Swift package that mirrors your `products` list and ensures SPM libraries are linked into the final binary. + +After running the integration tasks, **disable User Script Sandboxing** (`ENABLE_USER_SCRIPT_SANDBOXING = NO`) in the `.xcodeproj`. Xcode 16+ enables it by default, which prevents the Gradle build phase from writing to the project directory: + +```bash +sed -i '' 's/ENABLE_USER_SCRIPT_SANDBOXING = YES/ENABLE_USER_SCRIPT_SANDBOXING = NO/g' "$XCODEPROJ_PATH/project.pbxproj" +``` + +If the setting is absent (Xcode defaults to YES), add `ENABLE_USER_SCRIPT_SANDBOXING = NO;` to the app target's `buildSettings` sections. Then restart the Gradle daemon: `./gradlew --stop` + +**Alternative (if xcodebuild approach fails):** See [troubleshooting.md](references/troubleshooting.md) § "Manual Integration Command Discovery" for a fallback script to discover paths and run integration tasks directly. + +### 5.2 Update Crashlytics dSYM upload script (if applicable) + +If the project uses FirebaseCrashlytics and has a dSYM upload run script phase (identified in Phase 1 step 11), update the script path from `${PODS_ROOT}/FirebaseCrashlytics/upload-symbols` to `"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run"`. See [troubleshooting.md](references/troubleshooting.md) § "Firebase Crashlytics: dSYM Upload Script" and [common-pods-mapping.md](references/common-pods-mapping.md) for the full script and input files list. + +### 5.3 Deintegrate CocoaPods + +**Option A: Full deintegration** (if CocoaPods was used ONLY for KMP dependencies): + +Before deleting files, run `git status --short` and verify the paths. If unsure, move files to a backup location instead of deleting immediately. + +```bash +cd /path/to/iosApp +pod deintegrate +rm -rf Podfile Podfile.lock Pods/ +# Remove the workspace that matches your app xcodeproj name +XCODEPROJ_NAME=$(basename "$(find . -maxdepth 1 -name "*.xcodeproj" -type d | grep -v Pods | head -1)" .xcodeproj) +rm -rf "${XCODEPROJ_NAME}.xcworkspace" +# Return to project root +cd .. +# Remove the migrated module podspec only (for example, shared.podspec) +# If unknown, list candidates and remove the matching one explicitly: +ls -1 *.podspec +# rm -f shared.podspec +``` + +This cleanup snippet is self-contained and does not assume `XCODEPROJ_PATH` or `GRADLE_PROJECT_PATH` from the earlier one-off migration command are still available in your shell. + +If `pod deintegrate` is not available, see [troubleshooting.md](references/troubleshooting.md) § "Manual CocoaPods Deintegration from pbxproj" for the full list of references to remove. Also remove `Pods/` from `.gitignore` and delete the `.xcworkspace` directory. + +**Option B: Partial removal** (if other non-KMP CocoaPods dependencies remain): + +Remove only the KMP pod line from the `Podfile` and re-run pod install: + +```ruby +target 'iosApp' do + # Remove this line: + pod 'shared', :path => '../shared' + # Keep other non-KMP pods +end +``` + +```bash +cd /path/to/iosApp && pod install +``` + +> **Tip:** Consider migrating remaining pods to SPM too — most popular iOS libraries support it natively. Add them in Xcode via File → Add Package Dependencies, then fully deintegrate CocoaPods once all pods are replaced. + +### 5.4 Manual integration (if automatic fails) + +See [troubleshooting.md](references/troubleshooting.md) § "Manual Xcode Integration Steps" for the 5-step manual setup (build phase, sandboxing, linkage package). + +--- + +## Phase 6: Remove CocoaPods from Gradle + +Now that the iOS project is reconfigured, remove the CocoaPods plugin and block from **all** modules that used it (not just the primary one): + +### 6.1 Remove CocoaPods plugin + +Remove `kotlin("native.cocoapods")` or `alias(libs.plugins.kotlinCocoapods)` from `plugins {}` in every module that used it: + +```kotlin +plugins { + // REMOVE: kotlin("native.cocoapods") or alias(libs.plugins.kotlinCocoapods) + alias(libs.plugins.kotlinMultiplatform) // Keep +} +``` + +If all modules have been migrated, also remove the `kotlinCocoapods` plugin entry from `gradle/libs.versions.toml`: + +```toml +[plugins] +# REMOVE: kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } +``` + +### 6.2 Remove cocoapods block + +Delete the entire `cocoapods { ... }` block from `build.gradle.kts`. The `swiftPMDependencies {}` block and `binaries.framework {}` configuration added in Phase 3 replace it. Also delete any generated `.podspec` files from the module directory (e.g., `shared/shared.podspec`) — these were generated by the CocoaPods plugin and are no longer needed. + +### 6.3 Remove deprecated gradle.properties entries + +If found in Phase 1.1, remove from `gradle.properties`: + +```properties +# REMOVE — no longer needed after migrating away from CocoaPods (KT-64096) +kotlin.apple.deprecated.allowUsingEmbedAndSignWithCocoaPodsDependencies=true +``` + +### 6.4 Clean up CocoaPods-related extras + +Review the extras identified in Phase 1 step 12. Podspec metadata, `noPodspec()`, CocoaPods task hooks, and `Pods.xcodeproj` patching code are **safe to remove** without user consultation. Non-standard pod configurations (`extraOpts`, `moduleName`), custom cinterop `defFile` setups, and CocoaPods-specific compiler/linker flags **require analysis** — consult the user if unsure whether SPM handles them automatically. + +See [cocoapods-extras-patterns.md](references/cocoapods-extras-patterns.md) for the full categorized list with examples. + +--- + +## Phase 7: Verification + +**Do NOT stop until the application builds successfully.** This phase is iterative — if any step fails, diagnose the error, fix it (consulting [troubleshooting.md](references/troubleshooting.md) and re-checking Phases 2–6), and re-run the failing step. Repeat until the build succeeds or the issue is clearly outside the migration scope (pre-existing bug, unrelated tooling problem). Do NOT write the migration report (Phase 8) until the build succeeds. + +### 7.1 Compile Kotlin code + +Compile the migrated module to verify Kotlin sources are correct: + +```bash +./gradlew :moduleName:compileKotlinIosSimulatorArm64 +``` + +If compilation fails with unresolved references, check import transformations (Phase 4) and SwiftPM dependency declarations (Phase 3.2). Common causes: missing `importedClangModules`, wrong Clang module names, preserved bundled klib imports that should have been transformed (or vice versa). + +### 7.2 Link framework + +```bash +./gradlew :moduleName:linkDebugFrameworkIosSimulatorArm64 +``` + +If linking fails, check that all required SPM products are declared and that version constraints resolve correctly. Linking errors about missing symbols often indicate a product was omitted from `swiftPMDependencies` or a version mismatch caused API removal. + +### 7.3 Build iOS/macOS Xcode project + +After the Gradle steps succeed, build the Xcode project to verify the full application compiles. Use `-project *.xcodeproj` if all CocoaPods were removed (Option A), or `-workspace *.xcworkspace` if non-KMP CocoaPods remain (Option B): + +```bash +cd /path/to/iosApp +# Discover schemes and build (replace -project/-workspace as needed; for macOS use -destination 'platform=macOS'): +xcodebuild -project *.xcodeproj -list -json 2>/dev/null | python3 -c "import sys,json; schemes=json.load(sys.stdin)['project']['schemes']; [print(s) for s in schemes]" +xcodebuild -project *.xcodeproj -scheme "" -destination 'generic/platform=iOS Simulator' ARCHS=arm64 build +``` + +**If `checkSandboxAndWriteProtection` fails** — sandboxing was not disabled in Phase 5.1. Go back and apply the sandboxing fix from Phase 5.1, then retry. + +**If the pre-migration build was not verified** (Phase 1.0 fallback was used), warn the user: +> Note: The pre-migration build could not be fully verified. If build errors appear now, some may be pre-existing issues unrelated to the migration. Compare errors against the pre-migration build output to distinguish migration issues from prior problems. + +### If the build fails + +**Do NOT revert the migration.** Read the error log, re-check Phases 2-6, and consult [troubleshooting.md](references/troubleshooting.md). If unsure, present options to the user — do not silently undo migration work. Fix the issue and re-run the failing verification step. Keep iterating until the build succeeds. + +--- + +## Phase 8: Migration Report + +After the build succeeds, write a comprehensive `MIGRATION_REPORT.md` in the project root. Use the template in [migration-report-template.md](references/migration-report-template.md). + +The report must include: +1. **Pre-Migration State** — CocoaPods dependencies (name, version, `linkOnly`), framework config, `cocoapods.*` imports, non-KMP pods, atypical configuration +2. **Migration Steps** — exact changes per phase with before/after snippets for non-trivial changes +3. **Import Transformations** — table of every import change, clearly marking preserved `cocoapods.*` imports and which bundled klib provides them +4. **Errors Encountered** — structured `Error #N` entries: phase, exact symptom, root cause, fix, generalizable flag +5. **Non-Trivial Decisions** — `isStatic` changes, preserved imports, framework search paths, trade-offs +6. **Files Changed** — complete list grouped by type (Gradle, Kotlin, Xcode, created, deleted) + +--- + +## Additional Resources + +- [DSL Reference](references/dsl-reference.md) - Full swiftPMDependencies syntax +- [Common Pods Mapping](references/common-pods-mapping.md) - Pod to SPM mapping table +- [CocoaPods Extras Patterns](references/cocoapods-extras-patterns.md) - Detection and cleanup patterns for CocoaPods workarounds +- [Troubleshooting](references/troubleshooting.md) - Issues, solutions, rollback +- [Migration Report Template](references/migration-report-template.md) - Post-migration report template diff --git a/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/cocoapods-extras-patterns.md b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/cocoapods-extras-patterns.md new file mode 100644 index 0000000..15d9ae5 --- /dev/null +++ b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/cocoapods-extras-patterns.md @@ -0,0 +1,47 @@ +# CocoaPods Extras Patterns + +Patterns to look for in `build.gradle.kts` files beyond the standard `cocoapods {}` block. These are workarounds, hacks, and glue code that projects accumulate over time to work around CocoaPods limitations. + +## Detection Patterns (Phase 1 step 11) + +- **Custom tasks that hook into CocoaPods tasks** — e.g., tasks registered with `tasks.named("podInstall") { finalizedBy(...) }` or `tasks.register("fixXcodeProject")` that patch `Pods.xcodeproj/project.pbxproj` to fix paths, tweak build settings, or work around CocoaPods quirks. These are pure CocoaPods workarounds and become dead code after migration. +- **Pods.xcodeproj patching** — any code that reads/writes `Pods.xcodeproj` files (e.g., replacing Gradle invocation paths, fixing scheme settings). The `Pods.xcodeproj` will no longer exist after migration. +- `cocoapods.summary`, `cocoapods.homepage`, `cocoapods.version`, `cocoapods.name` — podspec metadata (safe to remove) +- `cocoapods.podfile` — explicit Podfile path reference +- `cocoapods.extraSpecAttributes` — custom podspec attributes +- `pod("...", extraOpts = ...)` or `pod("...", moduleName = ...)` — non-standard pod configurations +- `noPodspec()` — disables podspec generation +- **Any code referencing `Pods/` directory, `.xcworkspace`, or `podspec` files** — build logic, path constants, or task inputs/outputs tied to CocoaPods artifacts +- Compiler flags or linker settings added specifically for CocoaPods interop (e.g., `-framework`, cinterop `defFile` for pod headers) + +--- + +## Phase 6.4 Cleanup Categories + +### Safe to remove (no user consultation needed) + +- `cocoapods.summary`, `cocoapods.homepage`, `cocoapods.version`, `cocoapods.name` — podspec metadata, not used by SPM +- `cocoapods.podfile = project.file(...)` — Podfile path reference, not used by SPM +- `cocoapods.extraSpecAttributes` — podspec attributes, not used by SPM +- `noPodspec()` — podspec generation flag, not used by SPM +- Custom Gradle tasks that hook into CocoaPods tasks (`podInstall`, `podSetup`, `generatePodspec`) — these tasks no longer exist without the plugin. This includes any tasks registered via `tasks.named("podInstall") { finalizedBy(...) }` or similar wiring. Example of dead code to remove entirely: + ```kotlin + // REMOVE — CocoaPods workaround, no longer needed + tasks.register("fixXcodeProject") { + doLast { + val xcodeProjectFile = project.file("../iosApp/Pods/Pods.xcodeproj/project.pbxproj") + // ... patching Pods.xcodeproj paths ... + } + } + tasks.named("podInstall") { finalizedBy("fixXcodeProject") } + ``` +- Any code that reads/writes `Pods.xcodeproj` files — the `Pods/` directory will no longer exist +- References to `Pods/` directory paths, `.xcworkspace` files, or `podspec` files in build configurations + +### Requires analysis — consult the user if unsure + +- `pod("...", extraOpts = ...)` — extra options may indicate special compilation flags needed. Check if the underlying library needs equivalent flags in `swiftPMDependencies` (e.g., `importedClangModules`, platform constraints) +- `pod("...", moduleName = ...)` — custom module name may indicate the Clang module name differs from the pod name. This likely maps to an `importedClangModules` entry in the SPM package declaration +- Custom cinterop `defFile` configurations for pod headers — these may need to be adapted or may no longer be needed if the SwiftPM import handles the headers automatically. Present findings to the user before removing +- Compiler or linker flags added specifically for CocoaPods interop (e.g., `-framework Pod`, custom `cinterops {}` blocks) — analyze whether the SPM integration handles this automatically. If unclear, present the flags to the user and ask whether they are still needed +- Any custom task wiring or build logic that references CocoaPods outputs — explain what the task does and ask the user whether equivalent functionality is needed diff --git a/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/common-pods-mapping.md b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/common-pods-mapping.md new file mode 100644 index 0000000..493cf37 --- /dev/null +++ b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/common-pods-mapping.md @@ -0,0 +1,474 @@ +# Common Pods to SwiftPM Mapping + +Reference for migrating popular CocoaPods dependencies to SwiftPM. + +## Firebase Suite + +All Firebase products come from a single repository: `https://github.com/firebase/firebase-ios-sdk.git` + +**Key facts:** +- SPM product names match CocoaPods pod names (e.g., pod `FirebaseAuth` → product `FirebaseAuth`) +- Exception: Beta products have a `-Beta` suffix in SPM (e.g., `FirebaseAppDistribution-Beta`) +- The CocoaPods umbrella pod `Firebase` does not exist in SPM — import specific products +- **Platform requirements**: iOS 15+, macOS 10.15+, tvOS 15+, watchOS 7+ +- **Xcode**: 16.2+ + +> **WARNING: Do not mix Firebase across CocoaPods and SPM.** All Firebase products share a single repository and common transitive dependencies (gRPC, abseil, leveldb, BoringSSL, nanopb, etc.). If some Firebase pods remain in CocoaPods while others are added via SPM, the shared transitive dependencies get linked twice with conflicting symbols, causing **dyld crashes at runtime** (e.g., `Symbol not found: _OBJC_CLASS_$_FIRFirestore`). When migrating Firebase, move **all** Firebase pods to SPM at once — including Swift-only pods (FirebaseAI, FirebaseFunctions, FirebaseMLModelDownloader) that Kotlin cannot use directly. Add Swift-only pods as `products` entries without `importedClangModules`. After adding new products, re-run `integrateLinkagePackage` to regenerate the linkage Swift package. + +### Firebase SPM Products Reference + +| CocoaPods Pod | SPM Product | Platform | KMP Notes | +|---------------|-------------|----------|-----------| +| FirebaseAnalytics | FirebaseAnalytics | All | ObjC classes: `FIRAnalytics`, `FIRApp` | +| FirebaseAuth | FirebaseAuth | All (partial on macOS/tvOS/watchOS) | ObjC classes: `FIRAuth`, `FIRUser` | +| FirebaseCore | FirebaseCore | All | ObjC class: `FIRApp` | +| FirebaseCrashlytics | FirebaseCrashlytics | All | ObjC class: `FIRCrashlytics` | +| FirebaseDatabase | FirebaseDatabase | All | **importedClangModules: `FirebaseDatabaseInternal`** — ObjC classes: `FIRDatabase`, `FIRDatabaseReference` | +| FirebaseFirestore | FirebaseFirestore | All | **Special case** — see below | +| FirebaseFunctions | FirebaseFunctions | All | Swift-only — no `importedClangModules` entry needed | +| FirebaseMessaging | FirebaseMessaging | All | ObjC classes: `FIRMessaging` | +| FirebaseRemoteConfig | FirebaseRemoteConfig | All | **importedClangModules: `FirebaseRemoteConfigInternal`** — ObjC class: `FIRRemoteConfig` | +| FirebaseStorage | FirebaseStorage | All | ObjC class: `FIRStorage` | +| FirebaseAppCheck | FirebaseAppCheck | All (watchOS 9+) | ObjC class: `FIRAppCheck` | +| FirebasePerformance | FirebasePerformance | iOS/tvOS only | ObjC class: `FIRPerformance` | +| FirebaseInAppMessaging | FirebaseInAppMessaging-Beta | iOS/tvOS only | `-Beta` suffix in SPM, **importedClangModules: `FirebaseInAppMessagingInternal`** | +| FirebaseAppDistribution | FirebaseAppDistribution-Beta | iOS only | Note `-Beta` suffix in SPM | +| FirebaseInstallations | FirebaseInstallations | All | ObjC class: `FIRInstallations` | +| FirebaseABTesting | *(no SPM product)* | All | **Module-only**: pulled transitively by RemoteConfig. List in `importedClangModules` only | +| FirebaseAILogic | FirebaseAI | All | **Renamed in SPM**. Swift-only — no `importedClangModules` entry needed | +| FirebaseMLModelDownloader | FirebaseMLModelDownloader | All | Swift-only — no `importedClangModules` entry needed | + +### FirebaseAnalytics + +```kotlin +// CocoaPods +pod("FirebaseAnalytics") { version = "12.5.0" } + +// SwiftPM — use same version as pod +swiftPackage( + url = "https://github.com/firebase/firebase-ios-sdk.git", + version = "12.5.0", + products = listOf("FirebaseAnalytics"), +) +``` + +**Kotlin import:** +```kotlin +import swiftPMImport...FIRAnalytics +import swiftPMImport...FIRApp +``` + +### FirebaseAuth + +```kotlin +// CocoaPods +pod("FirebaseAuth") { version = "12.5.0" } + +// SwiftPM — use same version as pod +swiftPackage( + url = "https://github.com/firebase/firebase-ios-sdk.git", + version = "12.5.0", + products = listOf("FirebaseAuth"), +) +``` + +**Kotlin import:** +```kotlin +import swiftPMImport...FIRAuth +import swiftPMImport...FIRUser +``` + +### FirebaseDatabase + +Database's Clang module name differs from its SPM product name. You **must** specify `importedClangModules` (requires typed API): + +```kotlin +// CocoaPods +pod("FirebaseDatabase") { version = "12.5.0" } + +// SwiftPM - Note the importedClangModules parameter (typed API required) +swiftPackage( + url = url("https://github.com/firebase/firebase-ios-sdk.git"), + version = from("12.5.0"), + products = listOf(product("FirebaseDatabase")), + importedClangModules = listOf("FirebaseDatabaseInternal"), +) +``` + +**Kotlin import:** +```kotlin +import swiftPMImport...FIRDatabase +import swiftPMImport...FIRDatabaseReference +``` + +### FirebaseFirestore (Special Case) + +Firestore's Clang module name differs from its SPM product name. You **must** specify `importedClangModules` (requires typed API): + +```kotlin +// CocoaPods +pod("FirebaseFirestore") { version = "12.5.0" } + +// SwiftPM - Note the importedClangModules parameter (typed API required) +swiftPackage( + url = url("https://github.com/firebase/firebase-ios-sdk.git"), + version = from("12.5.0"), + products = listOf(product("FirebaseFirestore")), + importedClangModules = listOf("FirebaseFirestoreInternal"), +) +``` + +**Kotlin import:** +```kotlin +import swiftPMImport...FIRFirestore +import swiftPMImport...FIRDocumentReference +``` + +**Why is this needed?** Firestore distributes as a binary xcframework. The internal Clang module exposed to Objective-C is named `FirebaseFirestoreInternal`, not `FirebaseFirestore`. Without `importedClangModules`, the KMP compiler cannot discover the Objective-C headers. + +### FirebaseCrashlytics + +```kotlin +// CocoaPods +pod("FirebaseCrashlytics") { version = "12.5.0" } + +// SwiftPM — use same version as pod +swiftPackage( + url = "https://github.com/firebase/firebase-ios-sdk.git", + version = "12.5.0", + products = listOf("FirebaseCrashlytics"), +) +``` + +**iOS project requirement:** Crashlytics needs a dSYM upload run script in the Xcode build phases. After migration, add a "Run Script" phase at the END of build phases: + +```bash +"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run" +``` + +With input files: +``` +${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME} +${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME} +${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist +$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist +$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) +``` + +Also set **Debug Information Format** to `DWARF with dSYM File` for all build configurations. + +### Combined Firebase Example + +When using multiple Firebase products, declare them in a single package. **Set `discoverClangModulesImplicitly = false`** — Firebase's transitive C++ dependencies (gRPC, abseil, leveldb, BoringSSL) contain Clang modules that fail cinterop. Explicitly list only the modules you need. + +```kotlin +swiftPMDependencies { + discoverClangModulesImplicitly = false + + // Combined Firebase requires typed API for importedClangModules control + swiftPackage( + url = url("https://github.com/firebase/firebase-ios-sdk.git"), + version = from("12.5.0"), + products = listOf( + product("FirebaseAnalytics"), + product("FirebaseAuth"), + product("FirebaseDatabase"), + product("FirebaseFirestore"), + product("FirebaseCrashlytics"), + product("FirebaseMessaging"), + product("FirebaseRemoteConfig"), + // Swift-only pods (products only, no importedClangModules): + product("FirebaseAI"), + product("FirebaseFunctions"), + ), + importedClangModules = listOf( + "FirebaseAnalytics", + "FirebaseAuth", + "FirebaseCore", + "FirebaseCrashlytics", + "FirebaseDatabaseInternal", // Not "FirebaseDatabase" + "FirebaseFirestoreInternal", // Not "FirebaseFirestore" + "FirebaseMessaging", + "FirebaseRemoteConfigInternal", // Not "FirebaseRemoteConfig" + "FirebaseABTesting", // Module-only, no product + ), + ) +} +``` + +### Firebase importedClangModules Reference + +Several Firebase products expose ObjC headers through Clang modules whose names differ from the SPM product name: + +| SPM Product | Clang Module (importedClangModules) | Notes | +|---|---|---| +| FirebaseAnalytics | FirebaseAnalytics | Same name | +| FirebaseAuth | FirebaseAuth | Same name | +| FirebaseCore | FirebaseCore | Same name | +| FirebaseCrashlytics | FirebaseCrashlytics | Same name | +| FirebaseDatabase | **FirebaseDatabaseInternal** | Different | +| FirebaseFirestore | **FirebaseFirestoreInternal** | Different | +| FirebaseInAppMessaging-Beta | **FirebaseInAppMessagingInternal** | Different | +| FirebaseRemoteConfig | **FirebaseRemoteConfigInternal** | Different | +| FirebaseInstallations | FirebaseInstallations | Same name | +| FirebaseMessaging | FirebaseMessaging | Same name | +| FirebasePerformance | FirebasePerformance | Same name | +| FirebaseStorage | FirebaseStorage | Same name | +| FirebaseAppCheck | FirebaseAppCheck | Same name | +| FirebaseAppDistribution-Beta | FirebaseAppDistribution | Same name (no `-Beta`) | +| *(transitive)* | **FirebaseABTesting** | Module-only, no product | +| FirebaseAI | *(none)* | Swift-only, no cinterop | +| FirebaseFunctions | *(none)* | Swift-only, no cinterop | +| FirebaseMLModelDownloader | *(none)* | Swift-only, no cinterop | + +**Note:** When `discoverClangModulesImplicitly = false` (recommended for Firebase), you must list every Clang module you import in `importedClangModules`. When `true` (default), `importedClangModules` is ignored — but this will fail for Firebase due to C++ transitive dependencies. + +### Firebase Initialization + +Ensure `GoogleService-Info.plist` is included in the iOS app target. In the app's entry point: + +```swift +import Firebase +FirebaseApp.configure() // Must be called before using any Firebase service +``` + +--- + +## Google Maps + +Repository: `https://github.com/googlemaps/ios-maps-sdk.git` + +**Key facts:** +- **iOS 16+ only** — no macOS, tvOS, or watchOS support +- **Xcode 16.0+** required +- Must use `exact()` version — `from()` will fail to resolve +- Single SPM product: `GoogleMaps` (wraps a binary xcframework via `GoogleMapsTarget`) +- CocoaPods subspec `GoogleMaps/Maps` maps to the single `GoogleMaps` SPM product +- Requires a Google Maps Platform API key configured in the iOS app +- Check [releases](https://github.com/googlemaps/ios-maps-sdk/releases) for available SPM versions + +```kotlin +// CocoaPods +pod("GoogleMaps") { version = "10.10.0" } + +// SwiftPM — use the exact same version as the pod +swiftPackage( + url = "https://github.com/googlemaps/ios-maps-sdk.git", + version = "10.10.0", + products = listOf("GoogleMaps"), +) +``` + +**Kotlin import:** +```kotlin +import swiftPMImport...GMSMapView +import swiftPMImport...GMSCameraPosition +import swiftPMImport...GMSMarker +import swiftPMImport...GMSServices +``` + +**iOS project requirement:** The API key must be set in the app delegate or SwiftUI app entry point: + +```swift +import GoogleMaps +GMSServices.provideAPIKey("YOUR_API_KEY") +``` + +--- + +## Google Sign-In + +Repository: `https://github.com/google/GoogleSignIn-iOS.git` + +**Key facts:** +- **iOS 12+, macOS 10.15+** — broad platform support +- Two SPM products: `GoogleSignIn` (core) and `GoogleSignInSwift` (SwiftUI support) +- CocoaPods pods: `GoogleSignIn` and `GoogleSignInSwiftSupport` +- Uses `from()` versioning (latest: 9.1.0) + +```kotlin +// CocoaPods +pod("GoogleSignIn") { version = "8.0.0" } + +// SwiftPM — use same version as pod +swiftPackage( + url = "https://github.com/google/GoogleSignIn-iOS.git", + version = "8.0.0", + products = listOf("GoogleSignIn"), +) +``` + +**Kotlin import:** +```kotlin +import swiftPMImport...GIDSignIn +import swiftPMImport...GIDSignInButton +``` + +**iOS project requirement:** Add `GIDClientID` to `Info.plist` and configure the URL scheme for OAuth redirect. See [Google Sign-In iOS docs](https://developers.google.com/identity/sign-in/ios/start-integrating). + +--- + +## LoremIpsum + +Simple text generation library with direct mapping. + +```kotlin +// CocoaPods +pod("LoremIpsum") { version = "2.0.1" } + +// SwiftPM — use same version as pod +swiftPackage( + url = "https://github.com/lukaskubanek/LoremIpsum.git", + version = "2.0.1", + products = listOf("LoremIpsum"), +) +``` + +**Kotlin import:** +```kotlin +import swiftPMImport...LoremIpsum +``` + +--- + +## Quick Reference Table + +| Pod Name | SPM Product | SPM Repository | Version Type | Platform | Notes | +|----------|-------------|----------------|--------------|----------|-------| +| FirebaseAnalytics | FirebaseAnalytics | firebase/firebase-ios-sdk.git | from() | All | | +| FirebaseAuth | FirebaseAuth | firebase/firebase-ios-sdk.git | from() | All | | +| FirebaseCore | FirebaseCore | firebase/firebase-ios-sdk.git | from() | All | | +| FirebaseCrashlytics | FirebaseCrashlytics | firebase/firebase-ios-sdk.git | from() | All | Needs dSYM upload script | +| FirebaseDatabase | FirebaseDatabase | firebase/firebase-ios-sdk.git | from() | All | importedClangModules: FirebaseDatabaseInternal | +| FirebaseFirestore | FirebaseFirestore | firebase/firebase-ios-sdk.git | from() | All | importedClangModules: FirebaseFirestoreInternal | +| FirebaseFunctions | FirebaseFunctions | firebase/firebase-ios-sdk.git | from() | All | Swift-only, no cinterop | +| FirebaseMessaging | FirebaseMessaging | firebase/firebase-ios-sdk.git | from() | All | | +| FirebaseRemoteConfig | FirebaseRemoteConfig | firebase/firebase-ios-sdk.git | from() | All | importedClangModules: FirebaseRemoteConfigInternal | +| FirebaseStorage | FirebaseStorage | firebase/firebase-ios-sdk.git | from() | All | | +| FirebasePerformance | FirebasePerformance | firebase/firebase-ios-sdk.git | from() | iOS/tvOS | | +| FirebaseInAppMessaging | FirebaseInAppMessaging-Beta | firebase/firebase-ios-sdk.git | from() | iOS/tvOS | `-Beta` suffix, importedClangModules: FirebaseInAppMessagingInternal | +| FirebaseAppDistribution | FirebaseAppDistribution-Beta | firebase/firebase-ios-sdk.git | from() | iOS only | `-Beta` suffix | +| FirebaseABTesting | *(none)* | firebase/firebase-ios-sdk.git | — | All | Module-only, importedClangModules only | +| FirebaseAILogic | FirebaseAI | firebase/firebase-ios-sdk.git | from() | All | Renamed, Swift-only | +| GoogleMaps | GoogleMaps | googlemaps/ios-maps-sdk.git | exact() | iOS 16+ only | | +| GoogleSignIn | GoogleSignIn | google/GoogleSignIn-iOS.git | from() | iOS 12+, macOS 10.15+ | | +| GoogleSignInSwiftSupport | GoogleSignInSwift | google/GoogleSignIn-iOS.git | from() | iOS 12+, macOS 10.15+ | SwiftUI support | +| LoremIpsum | LoremIpsum | lukaskubanek/LoremIpsum.git | from() | All | | + +--- + +## KMP Wrapper Libraries with Bundled Cinterop Klibs + +Some KMP libraries that wrap iOS SDKs ship pre-built cinterop klibs using the `cocoapods.*` package namespace. After migrating to SwiftPM, these `cocoapods.*` imports **must be preserved** — they resolve to the library's bundled klib, not to actual CocoaPods infrastructure. + +### KMPNotifier + +Repository: [https://github.com/mirzemehdi/KMPNotifier](https://github.com/mirzemehdi/KMPNotifier) +Maven: `io.github.mirzemehdi:kmpnotifier` + +**What it provides:** A KMP push notification library that wraps Firebase Cloud Messaging on iOS. The library bundles its own cinterop klib with namespace `cocoapods.FirebaseMessaging`, providing Kotlin bindings for `FIRMessaging`, `FIRMessagingAPNSTokenType`, and related classes. + +**Impact on migration:** +- When `swiftPMDependencies` generates cinterop bindings, it detects that `FirebaseMessaging` bindings already exist in KMPNotifier's klib and **skips generating new bindings** for that Clang module +- `import cocoapods.FirebaseMessaging.FIRMessaging` must remain unchanged — do NOT replace with `swiftPMImport.*` +- `FirebaseMessaging` should still be listed in `products` and `importedClangModules` for SPM linking, even though cinterop bindings won't be generated for it + +**Verifying bundled klib contents:** Use `klib dump-metadata-signatures` to inspect what a library's klib provides ([docs](https://kotlinlang.org/docs/native-libraries.html#using-kotlin-native-compiler)): + +```bash +find ~/.gradle/caches -name "*.klib" -path "*kmpnotifier*" | head -1 +klib dump-metadata-signatures /path/to/cinterop.klib | grep "FIRMessaging" +# Shows: cocoapods.FirebaseMessaging/FIRMessaging → confirms bundled klib +``` + +**Example — project using both KMPNotifier and GoogleSignIn:** +```kotlin +// IOSDelegate.kt — after migration +import cocoapods.FirebaseMessaging.FIRMessaging // KEEP — from kmpnotifier klib +import cocoapods.FirebaseMessaging.FIRMessagingAPNSTokenType // KEEP — from kmpnotifier klib +import swiftPMImport.com.example.app.GIDSignIn // REPLACE — direct cinterop +``` + +### dev.gitlive/firebase-kotlin-sdk + +Repository: [https://github.com/GitLiveApp/firebase-kotlin-sdk](https://github.com/GitLiveApp/firebase-kotlin-sdk) +Maven: `dev.gitlive:firebase-auth`, `dev.gitlive:firebase-firestore`, `dev.gitlive:firebase-storage`, etc. + +**What it provides:** Kotlin-first Firebase APIs for KMP. Unlike KMPNotifier, dev.gitlive libraries provide **high-level Kotlin APIs** — you typically don't use `cocoapods.*` imports directly. Instead, the Firebase pods were declared with `linkOnly = true` in CocoaPods to provide native linking only. + +**Impact on migration:** + +1. **Linker flags baked into published klibs.** The dev.gitlive klibs contain `-framework FirebaseCore`, `-framework FirebaseAuth`, etc. from the CocoaPods era. These persist when the consuming project switches to SPM. With SPM, Firebase frameworks land in per-product subdirectories (`$BUILT_PRODUCTS_DIR/FirebaseCore/FirebaseCore.framework`) that the K/N linker doesn't search automatically. + + **Fix:** Add per-product `-F` linkerOpts to `build.gradle.kts`: + ```kotlin + val builtProductsDir = System.getenv("BUILT_PRODUCTS_DIR") + if (builtProductsDir != null) { + listOf("FirebaseCore", "FirebaseAuth", "FirebaseCoreExtension", + "FirebaseCoreInternal", "FirebaseCrashlytics", "FirebaseFirestore", + "FirebaseFirestoreInternal", "FirebaseInstallations", "FirebaseMessaging", + "FirebaseStorage", "GoogleDataTransport", "GoogleUtilities", + "GTMSessionFetcher", "AppCheckCore", /* ... */).forEach { product -> + linkerOpts("-F", "$builtProductsDir/$product") + } + } + ``` + Also add matching `FRAMEWORK_SEARCH_PATHS` in the Xcode project for both Debug and Release. + +2. **Must use `isStatic = true`.** With a dynamic framework, the K/N linker creates `@rpath/FirebaseCore.framework/FirebaseCore` load instructions. Firebase SPM products are static libraries — their `.framework` bundles are not embedded in the app bundle. At runtime, `dyld` crashes with `Library not loaded`. Switching to `isStatic = true` embeds all symbols and defers unresolved framework flags to the final Xcode link. + +3. **iOS test tasks may fail.** The K/N test runner cannot find Firebase frameworks outside of Xcode context. You may need to disable iOS test tasks: + ```kotlin + tasks.matching { + (it.name.contains("Ios") || it.name.contains("ios")) && + (it.name.contains("Test") || it.name.contains("test")) + }.configureEach { enabled = false } + ``` + +--- + +## Researching Other Pods + +For pods not listed here: + +1. **Check GitHub repository** - Look for a `Package.swift` file in the repo +2. **Check CocoaPods spec** - The `source` field often points to the Git URL +3. **Search Swift Package Index** - https://swiftpackageindex.com/ +4. **Check library documentation** - Many libraries document SPM installation + +### Finding the Clang Module Name + +If you're unsure of the correct Clang module name: + +1. Keep `discoverClangModulesImplicitly = true` (default) +2. Run `./gradlew build` +3. Check build errors for available class names +4. Or check the library's `module.modulemap` file in its source + +### Identifying Bundled Cinterop Klibs in Unknown Libraries + +If you suspect a KMP library bundles its own cinterop klibs (common for libraries wrapping iOS SDKs), use the `klib` tool to inspect them ([docs](https://kotlinlang.org/docs/native-libraries.html#using-kotlin-native-compiler)): + +```bash +# Find klibs from a specific library in Gradle caches +find ~/.gradle/caches -name "*.klib" -path "*libraryName*" + +# Dump API signatures to see what namespaces and classes are provided +klib dump-metadata-signatures /path/to/library.klib | grep "cocoapods\." + +# If output shows cocoapods.* entries, the library bundles cinterop klibs +# Those cocoapods.* imports must be preserved after migration +``` + +Indicators that a library may bundle cinterop klibs: +- The project has `linkOnly = true` pod declarations for the same native SDK +- The library's documentation mentions CocoaPods integration or cinterop +- The library provides Kotlin APIs for an iOS SDK (Firebase, Maps, etc.) + +### Version Compatibility + +Do NOT bump dependency versions during migration — use the exact same version from the `cocoapods {}` block. Always: +1. **Use the same version.** If the pod was `version = "1.4.1"`, the SPM package must be `exact("1.4.1")`. Bumping versions can break cinterop APIs and introduce issues unrelated to the migration. +2. CocoaPods `version = "X.Y.Z"` (without `~>`) is an exact pin — use `exact("X.Y.Z")` in SPM, not `from()`. Only use `from()` when the CocoaPods spec used optimistic versioning (`~>`). +3. Check the GitHub releases page to confirm the exact version is available as an SPM release +4. Test thoroughly after migration diff --git a/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/dsl-reference.md b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/dsl-reference.md new file mode 100644 index 0000000..05e6ee5 --- /dev/null +++ b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/dsl-reference.md @@ -0,0 +1,293 @@ +# SwiftPM Import DSL Reference + +Complete reference for the `swiftPMDependencies {}` DSL in Kotlin Multiplatform. + +## Basic Structure + +`swiftPackage()` and `localSwiftPackage()` are annotated with `@ExperimentalKotlinGradlePluginApi` (warning level). Add the opt-in at the top of `build.gradle.kts`: + +```kotlin +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +``` + +```kotlin +kotlin { + iosArm64() + iosSimulatorArm64() + + swiftPMDependencies { + // Deployment versions + iosMinimumDeploymentTarget = "16.0" + macosMinimumDeploymentTarget = "13.0" + tvosMinimumDeploymentTarget = "16.0" + watchosMinimumDeploymentTarget = "9.0" + + // Module discovery (default: true) + discoverClangModulesImplicitly = true + + // Package declarations + swiftPackage(...) + localSwiftPackage(...) + } +} +``` + +--- + +## Package Declaration + +The DSL has two API forms. **Use the simple string API** for most packages. Use the typed API only when you need `exact()`, `branch()`, `revision()`, or platform constraints. + +### Simple API (Preferred) + +Plain strings for URL, version, and products. The `version` parameter maps to a **minimum version** (`from()`) internally. The `importedClangModules` defaults to the `products` list automatically. + +```kotlin +swiftPackage( + url = "https://github.com/owner/repo.git", + version = "1.0.0", // Equivalent to from("1.0.0") — minimum version + products = listOf("ProductName", "AnotherProduct"), +) +``` + +### Typed API (Advanced) + +Use when you need exact version pinning, branch tracking, platform constraints, or explicit Clang module control: + +```kotlin +swiftPackage( + url = url("https://github.com/owner/repo.git"), + version = exact("1.0.0"), + products = listOf( + product("ProductName"), + product("PlatformSpecific", platforms = setOf(iOS())) + ), + importedClangModules = listOf("CustomClangModuleName"), +) +``` + +### Remote Package (Swift Package Registry) + +```kotlin +swiftPackage( + repository = id("scope.package-name"), + version = from("1.0.0"), + products = listOf(product("ProductName")), + packageName = "package-name", +) +``` + +### Local Package + +```kotlin +localSwiftPackage( + directory = layout.projectDirectory.dir("../LocalPackage"), + products = listOf("LocalPackage"), +) +``` + +To create a new local package (e.g., a Swift/ObjC wrapper around a Swift-only library): + +```shell +cd /path/to/shared +mkdir LocalPackage && cd LocalPackage +swift package init --type library --name LocalPackage +``` + +Then use it in Kotlin: +```kotlin +// src/appleMain/kotlin/useLocalPackage.kt +import swiftPMImport...HelloFromLocalPackage + +@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) +fun useLocalPackage() { + HelloFromLocalPackage().hello() +} +``` + +--- + +## Version Specification + +| Syntax | Description | Use Case | +|--------|-------------|----------| +| `version = "1.0.0"` (simple API) | Minimum version — equivalent to `from("1.0.0")` | Most packages | +| `version = exact("1.0")` | Exact version pin | Strict dependencies, migration | +| `version = from("1.0")` | Minimum version (explicit) | Same as simple string | +| `version = branch("name")` | Git branch | Development, testing | +| `version = revision("hash")` | Git commit hash | Pinning specific commits | +| `version = range("1.0", "2.0")` | Version range | Constraining upper bound | + +**Important for migration:** The simple string `version = "X.Y.Z"` resolves to a minimum version (`from()`), which may pull a newer version than what was in CocoaPods. For exact version preservation during migration, use the typed API: `version = exact("X.Y.Z")`. + +--- + +## Product Configuration + +### Simple API + +```kotlin +products = listOf("FirebaseAnalytics", "FirebaseAuth") +``` + +With the simple API, `importedClangModules` defaults to the same list as `products`. This works when product names match Clang module names. + +### Typed API — Platform Constraints + +For packages that only support certain platforms, use the typed `product()` function: + +```kotlin +products = listOf( + product("GoogleMaps", platforms = setOf(iOS())) // iOS only +) +``` + +Available platforms: `iOS()`, `macOS()`, `tvOS()`, `watchOS()` + +### Typed API — Per-Product Clang Module Override + +```kotlin +products = listOf( + product("FirebaseDatabase", importedClangModules = setOf("FirebaseDatabaseInternal")) +) +``` + +--- + +## Module Import Configuration + +### Automatic Discovery (Default) + +By default, `discoverClangModulesImplicitly = true`. SwiftPM import automatically discovers and imports all accessible Clang modules. + +**IMPORTANT:** When `discoverClangModulesImplicitly = true`, the `importedClangModules` parameter is ignored. Only set `importedClangModules` when `discoverClangModulesImplicitly = false`. + +**IMPORTANT for Firebase:** Set `discoverClangModulesImplicitly = false` when using Firebase. Firebase's transitive C++ dependencies (gRPC, abseil, leveldb, BoringSSL) contain Clang modules that fail cinterop generation. Disable implicit discovery and explicitly list only the Firebase modules you need in `importedClangModules`. + +### Explicit Module Import + +When automatic discovery is disabled and the Clang module name differs from the product name, use the typed API: + +```kotlin +swiftPMDependencies { + discoverClangModulesImplicitly = false // Disable auto-discovery + + swiftPackage( + url = url("https://github.com/firebase/firebase-ios-sdk.git"), + version = from("12.6.0"), + products = listOf( + product("FirebaseAnalytics"), + product("FirebaseFirestore") + ), + importedClangModules = listOf( + "FirebaseAnalytics", + "FirebaseCore", + "FirebaseFirestoreInternal" // Note: different from product name + ), + ) +} +``` + +### When to Use importedClangModules + +| Scenario | Use importedClangModules? | +|----------|---------------------| +| Simple API, product name = Clang module name | No (auto-defaulted from products) | +| Product name != Clang module name | Yes (typed API) | +| Multiple modules per product | Yes (typed API) | +| Using discoverClangModulesImplicitly = false | Yes (typed API) | + +--- + +## Deployment Versions + +Set minimum deployment targets for each platform: + +```kotlin +swiftPMDependencies { + iosMinimumDeploymentTarget = "16.0" + macosMinimumDeploymentTarget = "13.0" + tvosMinimumDeploymentTarget = "16.0" + watchosMinimumDeploymentTarget = "9.0" +} +``` + +--- + +## Complete Example + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) +} + +group = "org.example.myproject" +version = "1.0-SNAPSHOT" + +kotlin { + iosArm64() + iosSimulatorArm64() + + // Framework configuration (moved from cocoapods block) + listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "SharedModule" + isStatic = true + } + } + + swiftPMDependencies { + iosMinimumDeploymentTarget = "16.0" + + // Simple API — most packages + swiftPackage( + url = "https://github.com/lukaskubanek/LoremIpsum.git", + version = "2.0.1", + products = listOf("LoremIpsum"), + ) + + // Simple API — Google Maps + swiftPackage( + url = "https://github.com/googlemaps/ios-maps-sdk.git", + version = "10.3.0", + products = listOf("GoogleMaps"), + ) + + // Local package + localSwiftPackage( + directory = layout.projectDirectory.dir("LocalWrapper"), + products = listOf("LocalWrapper"), + ) + } + + compilerOptions { + optIn.add("kotlinx.cinterop.ExperimentalForeignApi") + } +} +``` + +--- + +## Transitive Dependencies + +SwiftPM dependencies are handled automatically. When you run Kotlin/Native tests or link a framework, the Kotlin Gradle Plugin will provision necessary machine code from transitive SwiftPM dependencies. This behavior is automatic. + +You can optionally declare transitive dependencies explicitly to pin specific versions: + +```kotlin +swiftPMDependencies { + // Main dependency + swiftPackage( + url = "https://github.com/firebase/firebase-ios-sdk.git", + version = "12.5.0", + products = listOf("FirebaseAnalytics"), + ) + + // Transitive dependency with explicit version pin + swiftPackage( + url = url("https://github.com/apple/swift-protobuf.git"), + version = exact("1.32.0"), + products = listOf(product("SwiftProtobuf")), + ) +} +``` diff --git a/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/migration-report-template.md b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/migration-report-template.md new file mode 100644 index 0000000..dd7efcc --- /dev/null +++ b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/migration-report-template.md @@ -0,0 +1,145 @@ +# Migration Report Template + +After migration (whether successful or not), write a comprehensive `MIGRATION_REPORT.md` in the project root. This document serves both as human-readable documentation and as structured input for AI agents analyzing the migration. + +## Template + +```markdown +# Migration Report: CocoaPods to SwiftPM Import + +**Project:** +**Module migrated:** +**Date:** +**Kotlin version:** +**Status:** + +--- + +## Pre-Migration State + +### CocoaPods Dependencies + +| Pod | Version | Mode | Notes | +|-----|---------|------|-------| +| | | Regular / linkOnly | | + +### Framework Configuration + +- **baseName:** +- **isStatic:** +- **Deployment target:** + +### Kotlin Files Using `cocoapods.*` Imports + +| File | Imports | +|------|---------| +| | `cocoapods..`, ... | + +### Non-KMP CocoaPods + + + +### Atypical Project Configuration + + + +--- + +## Migration Steps + +### Phase 2: Gradle Configuration + + + +### Phase 3: swiftPMDependencies + + + +### Phase 4: Import Transformations + + + +| File | Before | After | Source | +|------|--------|-------|--------| +| | `cocoapods..` | `swiftPMImport...` | swiftPMImport cinterop | +| | `cocoapods..` | `cocoapods..` (unchanged) | bundled klib | + +### Phase 5: iOS Project Reconfiguration + + + +### Phase 6: CocoaPods Removal + + + +### Phase 7: Verification + + + +--- + +## Errors Encountered + + + +### Error #N: + +**Phase:** +**Symptom:** +**Root cause:** +**Fix:** +**Generalizable:** + +--- + +## Non-Trivial Decisions + + + +--- + +## Files Changed + + + +### Gradle Files +- + +### Kotlin Sources +- + +### Xcode Project Files +- + +### Created +- + +### Deleted +- +``` + +## Writing Guidelines + +- **Be specific.** Include actual file paths, class names, error messages. Avoid vague statements like "updated the config." +- **Show before/after.** For non-trivial changes, include code snippets of what was changed and why. +- **Explain the "why."** Every error and non-trivial decision should include root cause analysis, not just the fix. +- **Mark preserved `cocoapods.*` imports clearly.** These are the most confusing aspect of the migration for future readers — explain exactly why each one was kept and which library provides the bundled klib. +- **Flag generalizable issues.** Mark errors that are likely to affect other projects so this report can improve the migration tooling. +- **Keep it machine-parseable.** Use consistent markdown headings, tables, and the `Error #N` format so AI agents can extract structured data. diff --git a/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/troubleshooting.md b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/troubleshooting.md new file mode 100644 index 0000000..9b2f7ea --- /dev/null +++ b/.agents/skills/kotlin-tooling-cocoapods-spm-migration/references/troubleshooting.md @@ -0,0 +1,606 @@ +# Troubleshooting Guide + +Common issues and solutions when migrating from CocoaPods to SwiftPM. + +## Gradle Issues + +### Import Not Found After Migration + +**Symptom:** `Unresolved reference` errors for classes that worked with CocoaPods + +**Solution:** The import namespace follows a specific pattern: + +``` +swiftPMImport... +``` + +**Steps to fix:** +1. Check `group` property in build.gradle.kts +2. Replace `-` with `.` in both group and module names +3. Run `./gradlew build` to see available classes in error messages + +**Example:** +```kotlin +// If group = "org.jetbrains.kotlin.firebase-sample" and module = "kotlin-library" +// Import becomes: +import swiftPMImport.org.jetbrains.kotlin.firebase.sample.kotlin.library.FIRAnalytics +// ^ ^ ^ +// dashes become dots --------+------+ +``` + +--- + +### Gradle Sync Fails + +**Symptom:** IDE fails to sync project after adding swiftPMDependencies + +**Solution:** +1. Invalidate caches: File > Invalidate Caches > Invalidate and Restart +2. Run `./gradlew --refresh-dependencies` +3. Check all repository declarations include JetBrains Maven + +--- + +## Linker Issues + +### Missing Symbols / Linker Errors + +**Symptom:** `Undefined symbols for architecture` errors + +**Solutions:** + +1. **Run the linkage integration task** (one-time, not a build phase): + ```bash + ./gradlew :moduleName:integrateLinkagePackage + ``` + +2. **Verify SPM package is linked in Xcode:** + - Open project in Xcode + - Check Package Dependencies section + - Ensure `KotlinMultiplatformLinkedPackage` is present + +3. **Check framework configuration** — `isStatic = true` is recommended. While `isStatic = false` can work, dynamic frameworks have known edge cases with SwiftPM import (linker errors, dyld crashes, duplicate class warnings). It is required with `dev.gitlive:firebase-*` — see below. + +--- + +### "No such module" in Xcode + +**Symptom:** Xcode can't find the Kotlin module + +**Solution:** +1. Clean Xcode build folder: Shift+Cmd+K +2. Re-run integration: + ```bash + ./gradlew :moduleName:integrateLinkagePackage + ``` +3. Restart Xcode completely +4. Re-open the correct Xcode project file (`.xcodeproj` if all CocoaPods were removed, `.xcworkspace` if non-KMP CocoaPods remain) + +--- + +## Build Phase Issues + +### Build Phase Order Problems + +**Symptom:** Swift compilation fails because Kotlin framework isn't ready + +**Solution:** Ensure "Compile Kotlin" runs BEFORE "Compile Sources": + +1. Open Xcode project +2. Select app target > Build Phases +3. Drag "Compile Kotlin" phase above "Compile Sources" + +--- + +### Script Sandboxing Errors + +**Symptom:** Gradle task `checkSandboxAndWriteProtection` fails during Xcode build: + +``` +Execution failed for task ':moduleName:checkSandboxAndWriteProtection'. +> User Script Sandboxing Enabled in Xcode Project +``` + +Or build scripts can't access files or run Gradle. + +**Cause:** Xcode 16+ enables User Script Sandboxing by default. The Gradle build phase needs to write to the project directory, which sandboxing prevents. + +**Solution:** + +1. Disable via command line: + ```bash + sed -i '' 's/ENABLE_USER_SCRIPT_SANDBOXING = YES/ENABLE_USER_SCRIPT_SANDBOXING = NO/g' /path/to/iosApp/*.xcodeproj/project.pbxproj + ``` + If the setting is not present in the `.pbxproj` (Xcode defaults to YES without an explicit entry), open the project in Xcode instead. + +2. Or disable in Xcode: select app target → Build Settings → Build Options → set "User Script Sandboxing" to NO + +3. **Important:** After changing the setting, stop the Gradle daemon: + ```bash + ./gradlew --stop + ``` + +--- + +## Integration Task Issues + +### `integrateEmbedAndSign` Skipped or Does Nothing + +**Symptom:** Running `integrateEmbedAndSign` completes without errors but the Xcode project is not modified. The `embedAndSignAppleFrameworkForXcode` build phase is not added or remains commented out. + +**Cause:** The project has code that disables `EmbedAndSign` tasks. Common patterns: + +```kotlin +// In root or module build.gradle.kts +project.gradle.taskGraph.whenReady { + allTasks.filter { it::class.simpleName?.contains("EmbedAndSign") == true }.forEach { + it.enabled = false + } +} +``` + +This was a CocoaPods-era workaround that inadvertently disables `integrateEmbedAndSign`. + +**Solution:** Remove the disabler code from `build.gradle.kts`, then re-run the integration command. + +--- + +### `embedAndSignAppleFrameworkForXcode` Commented Out in Build Phase + +**Symptom:** Xcode build succeeds but produces no Kotlin framework. The app crashes at runtime with missing module errors. + +**Cause:** The Gradle invocation in the Xcode build phase script was commented out (prefixed with `#`) — possibly a pre-existing state from before migration. + +**Solution:** Open `project.pbxproj` and uncomment the Gradle invocation: + +```diff +-#./gradlew :moduleName:embedAndSignAppleFrameworkForXcode ++./gradlew :moduleName:embedAndSignAppleFrameworkForXcode +``` + +--- + +## Third-Party KMP Libraries with Bundled Klibs + +### `cocoapods.*` Class Not Found After Converting to `swiftPMImport.*` + +**Symptom:** After replacing `import cocoapods.FirebaseMessaging.FIRMessaging` with `import swiftPMImport...FIRMessaging`, the build fails with `Unresolved reference 'FIRMessaging'`. Other swiftPMImport classes (e.g., `GIDSignIn`) resolve fine. + +**Cause:** A third-party KMP library (e.g., [KMPNotifier](https://github.com/mirzemehdi/KMPNotifier) — `io.github.mirzemehdi:kmpnotifier`) bundles its own pre-built cinterop klib with the `cocoapods.FirebaseMessaging` namespace. The swiftPMDependencies cinterop generator detects these existing bindings and **deliberately skips** generating new bindings for that Clang module to avoid duplicate symbols. The `swiftPMImport.*` bindings for that module simply don't exist. + +**Solution:** Revert the affected imports back to `cocoapods.*`: + +```kotlin +// These resolve to the third-party library's bundled klib, NOT actual CocoaPods +import cocoapods.FirebaseMessaging.FIRMessaging +import cocoapods.FirebaseMessaging.FIRMessagingAPNSTokenType +``` + +The `cocoapods` prefix here is just a package namespace embedded in the library's published artifact — no CocoaPods infrastructure is needed at runtime. + +**How to identify bundled klibs in advance:** Check if the project depends on KMP libraries that wrap iOS SDKs. Known libraries: [KMPNotifier](https://github.com/mirzemehdi/KMPNotifier) (bundles `cocoapods.FirebaseMessaging`). Also check for `linkOnly = true` pod declarations — this indicates the pod was only needed for linking while a KMP library provided the actual bindings. + +**Inspecting klib contents:** Use `klib dump-metadata-signatures` to verify which classes a klib provides ([docs](https://kotlinlang.org/docs/native-libraries.html#using-kotlin-native-compiler)): + +```bash +# Find the klib +find ~/.gradle/caches -name "*.klib" -path "*kmpnotifier*" | head -1 + +# Dump and search for the class in question +klib dump-metadata-signatures /path/to/cinterop.klib | grep "FIRMessaging" +# Output shows: cocoapods.FirebaseMessaging.FIRMessaging → confirms bundled klib +``` + +You can also compare before/after migration by dumping the swiftPMImport klib: +```bash +# After build, find the swiftPMImport klib +find . -name "*.klib" -path "*swiftPMImport*" | head -1 + +# Verify which classes are available +klib dump-metadata-signatures /path/to/swiftPMImport.klib | grep "FIRMessaging" +# Empty output = class NOT in swiftPMImport (must use cocoapods.* import) +``` + +--- + +## dev.gitlive/firebase-kotlin-sdk Issues + +### `framework 'FirebaseCore' not found` (K/N Linker) + +**Symptom:** Kotlin/Native linker fails with: +``` +ld: framework 'FirebaseCore' not found +``` +or similar errors for `FirebaseAuth`, `FirebaseFirestore`, etc. The Gradle compilation succeeds but the link step fails. + +**Cause:** [firebase-kotlin-sdk](https://github.com/GitLiveApp/firebase-kotlin-sdk) (`dev.gitlive:firebase-*`) was published with CocoaPods-era cinterop klibs. These klibs have `-framework FirebaseCore`, `-framework FirebaseAuth`, etc. baked into their linker metadata. With CocoaPods, those frameworks were in `Pods/` on the search path. With SPM, they land in per-product subdirectories (`$BUILT_PRODUCTS_DIR/FirebaseCore/FirebaseCore.framework`) that the K/N linker doesn't search. + +**Solution (two-part):** + +**Part A — Gradle linkerOpts:** +```kotlin +iosTarget.binaries.framework { + val builtProductsDir = System.getenv("BUILT_PRODUCTS_DIR") + if (builtProductsDir != null) { + listOf( + "FirebaseCore", "FirebaseAuth", "FirebaseCoreExtension", + "FirebaseCoreInternal", "FirebaseCrashlytics", "FirebaseFirestore", + "FirebaseFirestoreInternal", "FirebaseInstallations", "FirebaseMessaging", + "FirebaseStorage", "GoogleDataTransport", "GoogleUtilities", + "GTMSessionFetcher", "AppCheckCore", "AppAuth", "GTMAppAuth", + ).forEach { product -> + linkerOpts("-F", "$builtProductsDir/$product") + } + } +} +``` + +The `if (builtProductsDir != null)` guard ensures `./gradlew :moduleName:compileKotlinIosSimulatorArm64` works without Xcode (compilation doesn't link). + +**Part B — Xcode FRAMEWORK_SEARCH_PATHS:** + +Add matching entries in `project.pbxproj` for both Debug and Release `buildSettings`: +``` +FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)/FirebaseCore", + "$(BUILT_PRODUCTS_DIR)/FirebaseAuth", + "$(BUILT_PRODUCTS_DIR)/FirebaseCoreExtension", + // ... same list as Part A ... +); +``` + +--- + +### `dyld: Library not loaded: @rpath/FirebaseCore.framework/FirebaseCore` (Runtime Crash) + +**Symptom:** The Gradle build and Xcode compilation both succeed, but the app crashes at launch with: +``` +dyld: Library not loaded: @rpath/FirebaseCore.framework/FirebaseCore + Referenced from: .../ComposeApp.framework/ComposeApp +``` + +**Cause:** The KMP framework is **dynamic** (`isStatic = false` or default). The K/N linker creates `LC_LOAD_DYLIB` entries (`@rpath/FirebaseCore.framework/FirebaseCore`). Firebase SPM products are **static** libraries — their `.framework` bundles exist in `$BUILT_PRODUCTS_DIR` during build but are NOT embedded in the app bundle. At runtime, `dyld` searches `@rpath` and finds nothing. + +**Solution:** Switch to a static framework: + +```kotlin +iosTarget.binaries.framework { + baseName = "Shared" + isStatic = true // Required when using dev.gitlive:firebase-* with SPM +} +``` + +With a static framework, all symbols are embedded in the `.a` archive. No `LC_LOAD_DYLIB` entries are created. Unresolved `-framework` flags from dev.gitlive klibs are deferred to the final Xcode app link, where `KotlinMultiplatformLinkedPackage` provides them. + +**After switching to static, also:** +1. Re-run `integrateLinkagePackage` — regenerates `Package.swift` with `type: .none` (static) +2. Remove any "Embed Frameworks" copy phase for the KMP framework — static frameworks must NOT be embedded +3. Add linker flags previously resolved by the K/N linker (e.g., `-framework Accelerate`, `-weak_framework CoreML`) to `OTHER_LDFLAGS` in the Xcode project + +--- + +## Firebase-Specific Issues + +### cinterop Failures on C++ Modules (gRPC, abseil, leveldb, BoringSSL) + +**Symptom:** Build fails with cinterop errors on modules like `grpc`, `absl`, `leveldb`, `openssl_grpc`, or other C++ transitive dependencies of Firebase. + +**Cause:** `discoverClangModulesImplicitly = true` (the default) makes Kotlin attempt cinterop on every Clang module in the dependency graph, including C++ modules that are not compatible. + +**Solution:** Set `discoverClangModulesImplicitly = false` and explicitly list only the Firebase Clang modules you need: + +```kotlin +swiftPMDependencies { + discoverClangModulesImplicitly = false + + swiftPackage( + url = url("https://github.com/firebase/firebase-ios-sdk.git"), + version = from("12.6.0"), + products = listOf(product("FirebaseAnalytics"), /* ... */), + importedClangModules = listOf("FirebaseAnalytics", "FirebaseCore", /* ... */), + ) +} +``` + +See [common-pods-mapping.md](common-pods-mapping.md) for the full importedClangModules reference. + +--- + +### Firebase Classes Not Found (Wrong Clang Module Name) + +**Symptom:** `Unresolved reference` for Firebase classes like `FIRDatabase`, `FIRRemoteConfig`, `FIRFirestore`, `FIRInAppMessaging` even though the product is listed. + +**Cause:** Several Firebase products expose ObjC headers through Clang modules whose names differ from the SPM product name. Using the product name in `importedClangModules` won't find the headers. + +**Solution:** Use the correct internal Clang module names: + +| SPM Product | Correct importedClangModules entry | +|---|---| +| FirebaseDatabase | `FirebaseDatabaseInternal` | +| FirebaseFirestore | `FirebaseFirestoreInternal` | +| FirebaseInAppMessaging-Beta | `FirebaseInAppMessagingInternal` | +| FirebaseRemoteConfig | `FirebaseRemoteConfigInternal` | + +--- + +### FirebaseFirestore Import Errors + +**Symptom:** Can't import FIRFirestore classes + +**Cause:** Firestore's Clang module name differs from product name. The internal Clang module exposed to Objective-C is `FirebaseFirestoreInternal`, not `FirebaseFirestore`. + +**Solution:** Add explicit importedClangModules: + +```kotlin +swiftPackage( + url = url("https://github.com/firebase/firebase-ios-sdk.git"), + version = from("12.6.0"), + products = listOf(product("FirebaseFirestore")), + importedClangModules = listOf("FirebaseFirestoreInternal"), // Required +) +``` + +--- + +### Firebase Crashlytics: dSYM Upload Script Broken After Migration + +**Symptom:** Crash reports don't appear in Firebase Console after migrating to SPM. Or the build phase fails with "No such file" errors referencing `${PODS_ROOT}/FirebaseCrashlytics/upload-symbols`. + +**Cause:** The CocoaPods-era dSYM upload script references `${PODS_ROOT}` which no longer exists. The SPM equivalent is at a different path. + +**Solution:** Update the existing "Run Script" build phase (or add one at the END if none exists): + +```bash +"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run" +``` + +With input files: +``` +${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME} +${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME} +${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist +$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist +$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH) +``` + +Also set **Debug Information Format** to `DWARF with dSYM File` for all build configurations in Build Settings. + +--- + +### Firebase Beta Products: SPM Name Differs + +**Symptom:** `FirebaseInAppMessaging` or `FirebaseAppDistribution` not found as SPM product + +**Cause:** Beta products have a `-Beta` suffix in SPM. + +**Solution:** Use the correct SPM product name: +- `FirebaseInAppMessaging` → `FirebaseInAppMessaging-Beta` +- `FirebaseAppDistribution` → `FirebaseAppDistribution-Beta` + +--- + +### dyld Crash When Mixing Firebase Across CocoaPods and SPM + +**Symptom:** App crashes at launch with a dyld error like: +``` +Symbol not found: _OBJC_CLASS_$_FIRFirestore +``` +or similar `_OBJC_CLASS_$_FIR*` symbol-not-found errors. The Gradle build and Xcode compilation both succeed, but the app crashes at runtime. + +**Cause:** Some Firebase pods were migrated to SPM while others remained in CocoaPods. All Firebase products share transitive dependencies (gRPC, abseil, leveldb, BoringSSL, nanopb). Having both package managers link these transitive dependencies causes duplicate/conflicting symbols that the dynamic linker cannot resolve. + +**Solution:** Migrate **all** Firebase pods to SPM at once. This includes Swift-only pods (FirebaseAI, FirebaseFunctions, FirebaseMLModelDownloader) that Kotlin cannot use directly — add them as `products` entries without `importedClangModules`: + +```kotlin +products = listOf( + // ObjC pods used by Kotlin: + product("FirebaseAnalytics"), + product("FirebaseAuth"), + // ... + // Swift-only pods (no importedClangModules needed): + product("FirebaseAI"), + product("FirebaseFunctions"), +), +``` + +After adding new products, re-run `integrateLinkagePackage` to regenerate the linkage Swift package. + +--- + +### Firebase Initialization Fails at Runtime + +**Symptom:** App crashes on Firebase initialization + +**Solution:** +1. Ensure `GoogleService-Info.plist` is in iOS app target +2. Call `FIRApp.configure()` before using any Firebase service +3. Check Firebase console for configuration issues + +--- + +## Google Maps Issues + +### GoogleMaps Version Not Found + +**Symptom:** SPM can't resolve GoogleMaps package + +**Solution:** GoogleMaps requires exact version matching: + +```kotlin +swiftPackage( + url = url("https://github.com/googlemaps/ios-maps-sdk.git"), + version = exact("10.6.0"), // Must use exact(), not from() + products = listOf( + product("GoogleMaps", platforms = setOf(iOS())) + ), +) +``` + +Check [releases page](https://github.com/googlemaps/ios-maps-sdk/releases) for valid versions. + +--- + +## KSP (Kotlin Symbol Processing) Compatibility + +### KSP after updating Kotlin version + +KSP should generally work with the target Kotlin version without any changes. Do NOT update KSP as part of the migration — it is out of scope. + +If KSP does fail (unlikely), the issue is unrelated to the CocoaPods-to-SwiftPM migration itself. Present the error to the user and let them decide how to handle it separately. + +--- + +## Manual Integration Command Discovery + +If the xcodebuild approach in Phase 5.1 fails, discover paths manually and run integration tasks directly: + +```bash +# Find iOS project directory (contains Podfile) +IOS_DIR=$(dirname "$(find . -name "Podfile" -type f | head -1)") + +# Find .xcodeproj (exclude Pods.xcodeproj) - use realpath for absolute path +XCODEPROJ=$(realpath "$(find "$IOS_DIR" -maxdepth 1 -name "*.xcodeproj" -type d | grep -v Pods | head -1)") + +# Find KMP module with swiftPMDependencies (module directory name) +KMP_MODULE=$(grep -rl "swiftPMDependencies" --include="build.gradle.kts" . | head -1 | xargs dirname | xargs basename) + +XCODEPROJ_PATH="$XCODEPROJ" \ +GRADLE_PROJECT_PATH=":$KMP_MODULE" \ +./gradlew ":$KMP_MODULE:integrateEmbedAndSign" ":$KMP_MODULE:integrateLinkagePackage" +``` + +--- + +## Manual CocoaPods Deintegration from pbxproj + +If `pod deintegrate` is not available, manually remove these CocoaPods references from `project.pbxproj`: + +- `Pods_.framework` build file and file reference +- `Pods-.debug.xcconfig` / `Pods-.release.xcconfig` file references +- `Pods` group and `Frameworks` group (if it only contained the Pods framework) +- `[CP] Check Pods Manifest.lock` shell script build phase +- `[CP] Embed Pods Frameworks` shell script build phase +- `baseConfigurationReference` lines pointing to Pods xcconfig files + +--- + +## Manual Xcode Integration Steps + +If the automatic `integrateEmbedAndSign` / `integrateLinkagePackage` tasks fail, set up the Xcode project manually: + +1. Open `.xcodeproj` (or `.xcworkspace` if non-KMP CocoaPods remain) +2. Add "Compile Kotlin" run script phase BEFORE "Compile Sources": + ```bash + cd "$SRCROOT/.." + ./gradlew :moduleName:embedAndSignAppleFrameworkForXcode + ``` +3. Set `ENABLE_USER_SCRIPT_SANDBOXING = NO` (Build Settings → Build Options → User Script Sandboxing) +4. Run `./gradlew --stop` to restart the Gradle daemon after changing sandboxing +5. Add local package: `../moduleName/KotlinMultiplatformLinkedPackage` + +--- + +## When Build Fails After Migration + +**Do NOT revert the migration as a first response.** Instead: + +1. **Read the full error log** — identify the actual failure type (Gradle resolution, import not found, linker error, Xcode build phase) +2. **Re-check each migration phase** — walk through Phases 2-6 and verify each step was applied. Common mistakes: + - Missing JetBrains Maven repo in `settings.gradle.kts` + - Wrong `group` or module name in import namespace (dashes not converted to dots) + - `cocoapods {}` block or plugin not fully removed (Phase 6) + - Wrong Xcode project file opened (`.xcodeproj` when non-KMP CocoaPods remain and `.xcworkspace` is needed, or vice versa) + - `isStatic = true` missing from framework config (required with dev.gitlive or similar CocoaPods-era wrapper klibs) + - `integrateLinkagePackage` not run + - EmbedAndSign disabler code not removed (prevents `integrateEmbedAndSign`) + - `embedAndSignAppleFrameworkForXcode` commented out in Xcode build phase + - `cocoapods.*` imports replaced that should have been preserved (bundled klib from third-party library) +3. **Consult the sections above** for specific error patterns +4. **If unsure, present options to the user** — describe what the logs show, list possible causes, and let the user decide + +--- + +## Rollback Instructions (Last Resort) + +Only revert if analysis above does not resolve the issue: + +### Step 1: Restore Git Files + +```bash +# Restore CocoaPods files (adjust path if iOS project is not in iosApp/) +git checkout -- "**/Podfile" "**/Podfile.lock" +git checkout -- *.podspec +git checkout -- **/build.gradle.kts +git checkout -- **/src/**/*.kt +``` + +### Step 2: Restore CocoaPods in build.gradle.kts + +```kotlin +plugins { + kotlin("native.cocoapods") // Re-add +} + +kotlin { + cocoapods { + // Restore original configuration + } + // Remove swiftPMDependencies block +} +``` + +### Step 3: Restore Kotlin Imports + +Change all imports back: +```kotlin +// FROM: +import swiftPMImport.group.module.ClassName + +// TO: +import cocoapods.PodName.ClassName +``` + +### Step 4: Reinstall CocoaPods + +```bash +# Navigate to directory containing Podfile (adjust path as needed) +cd # e.g., iosApp/, ios/, or project root +pod install +``` + +### Step 5: Open Workspace + +Open `*.xcworkspace` (not .xcodeproj) from the iOS project directory in Xcode. + +--- + +## Getting Help + +If issues persist: + +1. **Check sample projects:** + - [kmp-with-cocoapods-compose-sample (spm_import branch)](https://github.com/Kotlin/kmp-with-cocoapods-compose-sample/tree/spm_import) + - [kmp-with-cocoapods-firebase-sample (spm_import branch)](https://github.com/Kotlin/kmp-with-cocoapods-firebase-sample/tree/spm_import) + +2. **Run verbose build:** + ```bash + ./gradlew build --info + ``` + +3. **Check generated files:** + - Look in `moduleName/KotlinMultiplatformLinkedPackage/` for Package.swift + +4. **Inspect klib contents** using the `klib` tool ([docs](https://kotlinlang.org/docs/native-libraries.html#using-kotlin-native-compiler)): + ```bash + # Dump all API signatures from a klib + klib dump-metadata-signatures /path/to/library.klib + + # Search for specific classes + klib dump-metadata-signatures /path/to/library.klib | grep "ClassName" + + # Compare before/after — find klibs in build output + find . -name "*.klib" -path "*swiftPMImport*" # new swiftPMImport klibs + find ~/.gradle/caches -name "*.klib" -path "*libraryName*" # third-party klibs + ``` + This is particularly useful for verifying which classes are available in the swiftPMImport klib vs. bundled in third-party dependency klibs. diff --git a/.agents/skills/kotlin-tooling-immutable-collections-0-5-x-migration/SKILL.md b/.agents/skills/kotlin-tooling-immutable-collections-0-5-x-migration/SKILL.md new file mode 100644 index 0000000..7b47005 --- /dev/null +++ b/.agents/skills/kotlin-tooling-immutable-collections-0-5-x-migration/SKILL.md @@ -0,0 +1,172 @@ +--- +name: kotlin-tooling-immutable-collections-0-5-x-migration +description: > + Migrate Kotlin (and Java) code from kotlinx.collections.immutable 0.3.x / 0.4.x to the + latest 0.5.x. The 0.5.x line renames every copy-returning method on PersistentList / + PersistentMap / PersistentSet / PersistentCollection to a participial form per KEEP-0459 + (add→adding, removeAt→removingAt, set→replacingAt, put→putting, clear→cleared, …) and + deprecates the old names (WARNING, with ReplaceWith). Driven by the compiler: bump the + version, recompile, and apply the rename each deprecation warning names. Use when the user + mentions kotlinx.collections.immutable 0.5.x, PersistentList migration, "Use adding() + instead", KEEP-0459, or sees deprecation warnings from kotlinx.collections.immutable. +license: Apache-2.0 +metadata: + author: JetBrains + version: "2.4.0" +--- + +# kotlinx.collections.immutable 0.5.x Migration + +The 0.5.x line renames every copy-returning method on the persistent collections to a +participial form (per [KEEP-0459]) and deprecates the old names at `WARNING` level with a +`ReplaceWith` hint. Migrating is a mechanical, binary-compatible, semantics-preserving +call-site rename — same parameters, order, and return type; only the name changes. + +Drive it from the compiler: bump the version, recompile, and fix each deprecation warning — +the warning names the replacement. Source of truth: [`0.5.0-MIGRATION.md`]. + +## When it applies + +Check the version the project currently uses: + +- **0.3.x or 0.4.x** (any pre-0.5.0) → run the migration below. +- **On 0.5.x but not the latest** → set the version to the latest 0.5.x and stop. All 0.5.x + releases share the same renames, so a within-line bump adds no new deprecations and needs + no recompile. +- **On the latest 0.5.x, or on 0.6.x and later** → nothing to do. + +## Migration + +### 1. Find the build command + +Check `README.md`, `CLAUDE.md`, or `AGENTS.md` for how the project builds; if it isn't +written down, infer it from the build files — Gradle (`./gradlew`), Maven (`mvn`, or the +`./mvnw` wrapper), Bazel +(a `bazel` wrapper), or a custom script. Record the compile command (and the test command). +In a multi-module project you only need the modules that use the library, plus any you +change — not a whole-repo build. + +### 2. Baseline compile + +Compile on the current version and confirm it's green. If it doesn't build now, you can't +tell post-migration errors from pre-existing ones — get a working compile command first. + +### 3. Bump to the latest 0.5.x + +Find where the version is pinned — `grep -rn kotlinx-collections-immutable` across the build +files finds it (version catalog, build script, `gradle.properties`, `pom.xml`, …) — and set +it to the latest 0.5.x on [Maven Central] (a `-beta` is fine). If the build pins artifact +hashes (e.g. `gradle/verification-metadata.xml`), update those too — the cheapest fix is to +copy the new artifact's checksum straight from the dependency-verification failure message +and add just that one entry, rather than regenerating the whole metadata file. The bump is +binary-compatible; old code keeps compiling with warnings. (If the dependency fails to +resolve with a Kotlin metadata-version error, the project's Kotlin is too old for the 0.5.x +artifact — bump Kotlin first.) + +### 4. Recompile and fix the warnings + +Recompile. Each renamed method carries `@Deprecated(WARNING, ReplaceWith(...))`, so the +compiler emits one warning per call site naming the replacement (e.g. *"Use removingAll() +instead"*). Apply that rename. Repeat compile → fix until no `kotlinx.collections.immutable` +deprecation warnings remain. (For multiplatform, one target compile surfaces the shared call +sites. Pre-existing factory deprecations such as `immutableListOf` → `persistentListOf` +appear the same way — apply those too.) A recompile that fails right after the bump is +failing *on these deprecations* (plus, if hashes are pinned, a one-time dependency-verification +error) — keep applying the renames the warnings name; don't re-run dependency-resolution or +metadata-regeneration commands to try to clear it. + +**Trust the compiler — never find/replace by name.** The same method names exist on +`MutableList` / `MutableMap` / `MutableSet` and on the `.Builder` types, which mutate in +place and are *not* deprecated. Only the sites the compiler flags (receiver statically +`Persistent*`) get renamed; if it didn't flag it, leave it. + +**An `Unresolved reference` after a rename means the participial name isn't on that +receiver — you've split a rename.** A rename only compiles if the *declaration* and *every* +call site move together. The library already did that for the kotlinx types, so renaming +their call sites just works — but it doesn't hold for anything else that merely shares the +names. When a renamed call won't resolve, there are two cases: + +- The receiver is unrelated to this library (a `Mutable*`, a `.Builder`, a same-named method + on some other type) — the rename was wrong; revert that site. +- The receiver is a project type the codebase is itself migrating — it implements a + `Persistent*`, or it's the project's own wrapper whose methods echo these names and get + renamed to match. The rename is right but *half-done*: rename the declaration and its + other callers too, so the call resolves. (Deprecated overrides on an implementer are + step 5.) + +Decide by the receiver's *declared* type, never the method name — a `Persistent*`-named +field may hold another type. This matters most when you can't lean on a fast recompile and +are renaming from reading the source. + +**Java callers.** The recompile flags them only if the build reports javac deprecation +warnings (`-Xlint:deprecation`, usually off). If it doesn't, grep the `.java` files that +import the library for the old names and rename the calls whose receiver is a `Persistent*` +type. + +After the renames, the compiler may report some `@Suppress("DEPRECATION")` as having no +effect — remove those (re-read the region first, in case it still covers something else). + +### 5. Custom implementers + +If the project has classes that implement `PersistentList` / `PersistentMap` / +`PersistentSet` / `PersistentCollection`, their deprecated overrides need migrating too. +Find them: + +```bash +grep -rnE --include='*.kt' \ + '(class|object|interface)\s+\w[^:]*:\s*[^{]*\b(PersistentList|PersistentMap|PersistentSet|PersistentCollection)\s*<' . +``` + +On Windows PowerShell, `Select-String` is the `grep` equivalent: + +```powershell +Get-ChildItem -Recurse -Filter *.kt | + Select-String '(class|object|interface)\s+\w[^:]*:\s*[^{]*\b(PersistentList|PersistentMap|PersistentSet|PersistentCollection)\s*<' +``` + +(Confirm a match really lists the interface as a *supertype*, not just a field type or type +argument.) For each, move the implementation into the new participial method and have the +deprecated override delegate to it: + +```kotlin +override fun adding(element: E): MyList = /* real implementation */ + +@Suppress("OVERRIDE_DEPRECATION") +override fun add(element: E): MyList = adding(element) +``` + +If the participial methods call each other, route those calls through participial siblings, +not the deprecated names. (Add `"DEPRECATION"` to the suppress only when an override body +itself still calls a deprecated member.) Doing this now matters: at 0.6.0 the old names +become compile errors, and at 0.7.0 they are removed. See [`0.5.0-MIGRATION.md`] for the +upstream implementer guidance. + +### 6. Run any documented follow-up steps + +Do this *after* the renames compile clean, so that if you run low on time the call-site work +is already done. Some projects document steps to run after a dependency change that the +compiler won't surface — most commonly regenerating dependency-verification metadata (the +`gradle/verification-metadata.xml` hashes from step 3). Usually the single-entry fix from +step 3 is all you need; only fall back to the project's documented full-regeneration procedure +(in `README.md` / `CONTRIBUTING.md` / `CLAUDE.md` / `AGENTS.md`) if that one entry isn't +enough. Run it **once** — a full `--write-verification-metadata` / "resolve all dependencies" +pass re-resolves the entire graph and is slow, and repeating it rarely changes the outcome. +Then re-confirm the build is clean. + +## Rename reference + +- **`PersistentCollection`** — `add`→`adding`, `addAll`→`addingAll`, `remove`→`removing`, `removeAll`→`removingAll`, `retainAll`→`retainingAll`, `clear`→`cleared` +- **`PersistentList`** (the above, plus) — `add(i, e)`→`addingAt`, `addAll(i, c)`→`addingAllAt`, `set(i, e)`→`replacingAt`, `removeAt`→`removingAt` +- **`PersistentMap`** — `put`→`putting`, `putAll`→`puttingAll`, `remove(k)`→`removing`, `remove(k, v)`→`removing`, `clear`→`cleared` + +Builders (`PersistentList.Builder`, etc.) are **not** renamed — they mutate in place, so +their imperative names stay. + +## Links + +- [`0.5.0-MIGRATION.md`] — upstream guide (source of truth, incl. implementer details) +- [KEEP-0459] — naming rationale + +[KEEP-0459]: https://github.com/Kotlin/KEEP/blob/main/proposals/KEEP-0459-naming-conventions-for-copy-returning-operations.md +[`0.5.0-MIGRATION.md`]: https://github.com/Kotlin/kotlinx.collections.immutable/blob/master/docs/0.5.0-MIGRATION.md +[Maven Central]: https://central.sonatype.com/artifact/org.jetbrains.kotlinx/kotlinx-collections-immutable/versions diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/SKILL.md b/.agents/skills/kotlin-tooling-java-to-kotlin/SKILL.md new file mode 100644 index 0000000..e856280 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/SKILL.md @@ -0,0 +1,138 @@ +--- +name: kotlin-tooling-java-to-kotlin +description: > + Use when converting Java source files to idiomatic Kotlin, when user mentions + "java to kotlin", "j2k", "convert java", "migrate java to kotlin", or when + working with .java files that need to become .kt files. Handles framework-aware + conversion for Spring, Lombok, Hibernate, Jackson, Micronaut, Quarkus, Dagger/Hilt, + RxJava, JUnit, Guice, Retrofit, and Mockito. +license: Apache-2.0 +metadata: + author: JetBrains + version: "1.0.0" +--- + +# Java to Kotlin Conversion + +Convert Java source files to idiomatic Kotlin using a disciplined 4-step conversion +methodology with 5 invariants checked at each step. Supports framework-aware conversion +that handles annotation site targets, library idioms, and API preservation. + +## Workflow + +```dot +digraph j2k_workflow { + rankdir=TB; + "User specifies files" -> "Step 0: Scan & Detect"; + "Step 0: Scan & Detect" -> "Load framework guides"; + "Load framework guides" -> "Step 1: Convert"; + "Step 1: Convert" -> "Step 2: Write .kt"; + "Step 2: Write .kt" -> "Step 3: Git rename"; + "Step 3: Git rename" -> "Step 4: Verify"; + "Step 4: Verify" -> "Next file?" [label="pass"]; + "Step 4: Verify" -> "Fix issues" [label="fail"]; + "Fix issues" -> "Step 1: Convert"; + "Next file?" -> "Step 0: Scan & Detect" [label="batch: yes"]; + "Next file?" -> "Done" [label="no more files"]; +} +``` + +## Step 0: Scan & Detect Frameworks + +Before converting, scan the Java file's import statements to detect which frameworks +are in use. Load ONLY the matching framework reference files to keep context focused. + +### Framework Detection Table + +| Import prefix | Framework guide | +|---|---| +| `org.springframework.*` | [SPRING.md](references/frameworks/SPRING.md) | +| `lombok.*` | [LOMBOK.md](references/frameworks/LOMBOK.md) | +| `javax.persistence.*`, `jakarta.persistence.*`, `org.hibernate.*` | [HIBERNATE.md](references/frameworks/HIBERNATE.md) | +| `com.fasterxml.jackson.*` | [JACKSON.md](references/frameworks/JACKSON.md) | +| `io.micronaut.*` | [MICRONAUT.md](references/frameworks/MICRONAUT.md) | +| `io.quarkus.*`, `javax.enterprise.*`, `jakarta.enterprise.*` | [QUARKUS.md](references/frameworks/QUARKUS.md) | +| `dagger.*`, `dagger.hilt.*` | [DAGGER-HILT.md](references/frameworks/DAGGER-HILT.md) | +| `io.reactivex.*`, `rx.*` | [RXJAVA.md](references/frameworks/RXJAVA.md) | +| `org.junit.*`, `org.testng.*` | [JUNIT.md](references/frameworks/JUNIT.md) | +| `com.google.inject.*` | [GUICE.md](references/frameworks/GUICE.md) | +| `retrofit2.*`, `okhttp3.*` | [RETROFIT.md](references/frameworks/RETROFIT.md) | +| `org.mockito.*` | [MOCKITO.md](references/frameworks/MOCKITO.md) | + +If `javax.inject.*` is detected, check for Dagger/Hilt vs Guice by looking for other +imports from those frameworks. If ambiguous, load both guides. + +## Step 1: Convert + +Apply the conversion methodology from [CONVERSION-METHODOLOGY.md](references/CONVERSION-METHODOLOGY.md). + +This is a 4-step chain-of-thought process: +1. **Faithful 1:1 translation** — exact semantics preserved +2. **Nullability & mutability audit** — val/var, nullable types +3. **Collection type conversion** — Java mutable → Kotlin types +4. **Idiomatic transformations** — properties, string templates, lambdas + +Five invariants are checked after each step. If any invariant is violated, revert +to the previous step and redo. + +Apply any loaded framework-specific guidance during step 4 (idiomatic transformations). + +## Step 2: Write Output + +Write the converted Kotlin code to a `.kt` file with the same name as the original +Java file, in the same directory. + +## Step 3: Preserve Git History + +To preserve `git blame` history, use a two-phase approach: + +```bash +# Phase 1: Rename (creates rename tracking) +git mv src/main/java/com/example/Foo.java src/main/kotlin/com/example/Foo.kt +git commit -m "Rename Foo.java to Foo.kt" + +# Phase 2: Replace content (tracked as modification, not new file) +# Write the converted Kotlin content to Foo.kt +git commit -m "Convert Foo from Java to Kotlin" +``` + +If the project keeps Java and Kotlin in the same source root (e.g., `src/main/java/`), +rename in place: + +```bash +git mv src/main/java/com/example/Foo.java src/main/java/com/example/Foo.kt +``` + +If the project does not use Git, simply write the `.kt` file and delete the `.java` file. + +## Step 4: Verify + +After conversion, verify using [checklist.md](assets/checklist.md): +- Attempt to compile the converted file +- Run existing tests +- Check annotation site targets +- Confirm no behavioral changes + +## Batch Conversion + +When converting multiple files (a directory or package): + +1. **List all `.java` files** in the target scope +2. **Sort by dependency order** — convert leaf dependencies first (files that don't + import other files in the conversion set), then work up to files that depend on them +3. **Convert one file at a time** — apply the full workflow (steps 0-4) for each +4. **Track progress** — report which files are done, which remain +5. **Handle cross-references** — after converting a file, update imports in other Java + files if needed (e.g., if a class moved packages) + +For large batches, consider converting in packages (bottom-up from leaf packages). + +## Common Pitfalls + +See [KNOWN-ISSUES.md](references/KNOWN-ISSUES.md) for: +- Kotlin keyword conflicts (`when`, `in`, `is`, `object`) +- SAM conversion ambiguity +- Platform types from Java interop +- `@JvmStatic` / `@JvmField` / `@JvmOverloads` usage +- Checked exceptions and `@Throws` +- Wildcard generics → Kotlin variance diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/assets/checklist.md b/.agents/skills/kotlin-tooling-java-to-kotlin/assets/checklist.md new file mode 100644 index 0000000..6b629b3 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/assets/checklist.md @@ -0,0 +1,60 @@ +# Post-Conversion Verification Checklist + +Use this checklist after converting each Java file to Kotlin. + +## Compilation & Tests +- [ ] The `.kt` file compiles without errors +- [ ] All existing tests still pass +- [ ] No new compiler warnings introduced + +## Semantic Correctness +- [ ] No new side-effects or behavioural changes +- [ ] All public API signatures preserved (method names, parameter types, return types) +- [ ] Exception behaviour unchanged (same exceptions thrown in same conditions) + +## Annotations +- [ ] All annotations preserved from the original Java code +- [ ] Annotation site targets correct (`@field:`, `@get:`, `@set:`, `@param:`) +- [ ] No annotations accidentally dropped during conversion + +## Imports & Package +- [ ] Package declaration matches original +- [ ] All imports carried forward (except Java types that shadow Kotlin builtins) +- [ ] No new imports added unnecessarily + +## Documentation +- [ ] All Javadoc converted to KDoc format +- [ ] `{@code ...}` → backtick code in KDoc +- [ ] `{@link ...}` → `[...]` KDoc links +- [ ] `

` paragraph tags → blank lines +- [ ] `@param`, `@return`, `@throws` tags preserved +- [ ] Class-level and method-level documentation preserved + +## Nullability & Mutability +- [ ] Non-null types used only where provably non-null +- [ ] Nullable types (`?`) used for all Java types that could be null +- [ ] `val` used for all immutable variables/properties +- [ ] `var` used only for mutable variables/properties + +## Collections +- [ ] `MutableList`/`MutableSet`/`MutableMap` for Java's mutable collections +- [ ] `List`/`Set`/`Map` only where Java used immutable wrappers + +## Kotlin Idioms +- [ ] Getters/setters replaced with Kotlin properties where appropriate +- [ ] String concatenation replaced with string templates where clearer +- [ ] Elvis operator used where appropriate +- [ ] `when` expression used instead of `switch` +- [ ] Smart casts used after `is` checks (no explicit casts) + +## Framework-Specific (check applicable items) +- [ ] **Spring**: Classes that need proxying are `open`; `@Bean` methods are `open` +- [ ] **Lombok**: All Lombok annotations removed; replaced with Kotlin equivalents +- [ ] **Hibernate/JPA**: Entities are `open` (not data classes); no-arg constructor provided +- [ ] **Jackson**: `@field:` and `@get:` annotation site targets correct +- [ ] **RxJava**: Reactive types correctly mapped to Coroutines/Flow +- [ ] **Mockito**: `when` keyword escaped or replaced with MockK equivalent + +## Git History +- [ ] File renamed via `git mv` (not delete + create) +- [ ] Rename commit separate from content change commit diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/CONVERSION-METHODOLOGY.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/CONVERSION-METHODOLOGY.md new file mode 100644 index 0000000..908fb23 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/CONVERSION-METHODOLOGY.md @@ -0,0 +1,352 @@ +# Conversion Methodology + +You are a senior Kotlin engineer and Java-Kotlin JVM interop specialist. Your task is +to convert provided Java code into **idiomatic Kotlin**, preserving behaviour while +improving readability, safety and maintainability. + +## The 4-Step Precognition Process + +Before emitting any code, run through the provided Java input and perform these 4 steps +of thinking. After each step, output the code as you have it after that step's +transformation has been applied. + +### Step 1: Faithful 1:1 Translation + +Convert the Java code 1 to 1 into Kotlin, prioritising faithfulness to the original +Java semantics, to replicate the Java code's functionality and logic exactly. + +**Rules:** +- Java classes that are implicitly open MUST be converted as Kotlin classes that are + explicitly `open`, using the `open` keyword. +- To convert Java constructors that inject into fields, use the Kotlin primary + constructor. Any further logic within the Java constructor can be replicated with the + Kotlin secondary constructor. + +### Step 2: Nullability & Mutability + +Check that mutability and nullability are correctly expressed in your Kotlin conversion. +Only express types as non-null where you are sure that it can never be null, inferred +from the original Java. Use `val` instead of `var` where you see variables that are +never modified. + +**Rules:** +- If you see a logical assertion that a value is not null (e.g., `Objects.requireNonNull`), + this shows that the author has considered that the value can never be null. Use a + non-null type in this case, and remove the logical assertion. +- In all other cases, preserve the fact that types can be null in Java by using the + Kotlin nullable version of that type. + +### Step 3: Collection Type Conversion + +Convert datatypes like collections from their Java variants to the Kotlin variants. + +**Rules:** +- For Java collections like `List` that are mutable by default, always use the Kotlin + `MutableList`, unless you see explicitly that the Java code uses an immutable wrapper + (e.g., `Collections.unmodifiableList()`) — in this case, use the Kotlin `List` (and + so on for other collections like `Set`, `Map` etc.) + +### Step 4: Idiomatic Transformations + +Introduce syntactic transformations to make the output truly idiomatic. + +**Rules:** +- Where getters and setters are defined as methods in Java, use the Kotlin syntax to + replace these methods with a more idiomatic version. +- Lambdas should be used where they can simplify code complexity while replicating the + exact behaviour of the previous code. + +## The 5 Invariants + +In each stage of your chain of thought, the following invariants must hold. + +**Invariant 1:** No new side-effects or behaviour. + +**Invariant 2:** Preserve all annotations and targets exactly. +- Annotations must target the backing field in Kotlin where they targeted the field in + Java. Use annotation site targets: `@field:`, `@get:`, `@set:`, `@param:`. + +**Invariant 3:** Preserve the package declaration and all imports. +- Carry forwards every single import, adding no new imports. Only remove imports where + they would shadow Kotlin names (e.g., `java.util.List` shadows Kotlin's `List`). + +**Invariant 4:** Preserve all Javadoc comments. +- In step 1, convert any Javadoc comments to KDoc comments. +- In all other steps, carry forwards the KDoc comments, preserving information where + structure changes. + +**Invariant 5:** Ensure the output result is in Kotlin. +- The emitted code must be syntactically valid Kotlin. + +**After each step**, go through each of these invariants, listing the ones that no +longer hold. If any exist, revert to the previous step and recalculate from there. + +--- + +## Example 1: Utility Class with Nullability + +### Java Input + +```java +package com.acme.util; + +import java.time.LocalDate; +import java.util.Objects; + +/** +* Utility for printing a greeting that includes the current date. +*/ +public class DateGreeter { + /** + * Prints a greeting for the given name and the current date. + * + * @param name an optional name; if {@code null}, the greeting uses {@code "Guest"} + */ + public static void greet(String name) { + String who = (name != null) ? name : "Guest"; + System.out.println("Hello, " + who + " - today is " + LocalDate.now()); + } +} +``` + +### Conversion Walkthrough + +**Step 1** — Faithful translation. The DateGreeter class is implicitly open, the +`greet` method is static → companion object. Javadoc converted to KDoc. + +```kotlin +package com.acme.util + +import java.time.LocalDate +import java.util.Objects + +/** +* Utility for printing a greeting that includes the current date. +*/ +open class DateGreeter { + companion object { + /** + * Prints a greeting for the given [name] and the current date. + * + * @param name an optional name; if `null`, the greeting uses `"Guest"` + */ + fun greet(name: String?) { + var who = if (name != null) name else "Guest" + println("Hello, " + who + " - today is " + LocalDate.now()) + } + } +} +``` + +Invariants check: All OK. + +**Step 2** — The `String?` is correct since the author checks for null. The `who` +variable is only read, so change `var` → `val`. + +```kotlin +// Same as above but with: +val who = if (name != null) name else "Guest" +``` + +Invariants check: All OK. + +**Step 3** — No collections in this code. No changes. + +Invariants check: All OK. + +**Step 4** — The `greet` function is not tied to any state of DateGreeter, so move it +to a top-level function. Use string templates and Elvis operator. + +```kotlin +package com.acme.util + +import java.time.LocalDate +import java.util.Objects + +/** +* Prints a greeting for the given [name] and the current date. +* +* @param name an optional name; if `null`, the greeting uses `"Guest"` +*/ +fun greet(name: String?) { + println("Hello, ${name ?: "Guest"} - today is ${LocalDate.now()}") +} +``` + +Invariants check: All OK. + +--- + +## Example 2: Domain Model with Annotations + +### Java Input + +```java +package com.acme.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import javax.annotation.Nullable; +import java.util.Objects; + +/** +* Domain model for a user with a required identifier and an optional nickname. +*

+* The {@code id} is serialized as {@code "id"} and is required. +* The {@code nickname} may be absent. +*/ +public class User { + /** + * Stable, non-null identifier serialized as {@code "id"}. + */ + @JsonProperty("id") + private final String id; + + /** + * Optional nickname for display purposes. + */ + @Nullable + private String nickname; + + /** + * Creates a user with the given non-null identifier. + * + * @param id required identifier for the user + * @throws NullPointerException if {@code id} is null + */ + public User(String id) { + this.id = Objects.requireNonNull(id, "id"); + } + + /** + * Returns the identifier serialized as {@code "id"}. + * + * @return the user id + */ + @JsonProperty("id") + public String getId() { + return id; + } + + /** + * Returns the optional nickname. + * + * @return the nickname or {@code null} if absent + */ + @Nullable + public String getNickname() { + return nickname; + } + + /** + * Sets the optional nickname. + * + * @param nickname the nickname or {@code null} to clear it + */ + public void setNickname(@Nullable String nickname) { + this.nickname = nickname; + } +} +``` + +### Conversion Walkthrough + +**Step 1** — Faithful translation. Class is implicitly open → `open class`. +`@JsonProperty("id")` on the field → `@field:JsonProperty("id")`. +`@JsonProperty("id")` on the getter → `@get:JsonProperty("id")` when converted to +property later. Keep explicit getters/setters at this step for faithfulness. + +```kotlin +package com.acme.model + +import com.fasterxml.jackson.annotation.JsonProperty +import javax.annotation.Nullable +import java.util.Objects + +/** +* Domain model for a user with a required identifier and an optional nickname. +* +* The `id` is serialized as `"id"` and is required. +* The `nickname` may be absent. +*/ +open class User { + + /** + * Stable, non-null identifier serialized as `"id"`. + */ + @field:JsonProperty("id") + private val id: String + + /** + * Optional nickname for display purposes. + */ + @field:Nullable + private var nickname: String? = null + + /** + * Creates a user with the given non-null identifier. + * + * @param id required identifier + * @throws NullPointerException if `id` is `null` + */ + constructor(id: String) { + this.id = Objects.requireNonNull(id, "id") + } + + @get:JsonProperty("id") + fun getId(): String { return id } + + @Nullable + fun getNickname(): String? { return nickname } + + fun setNickname(@Nullable nickname: String?) { this.nickname = nickname } +} +``` + +Invariants check: All OK. + +**Step 2** — `id` is non-null by design (`Objects.requireNonNull` enforces it). +`nickname` is nullable (`@Nullable`). No val/var changes needed beyond what's already +done. Code unchanged. + +Invariants check: All OK. + +**Step 3** — No collections. No changes. + +Invariants check: All OK. + +**Step 4** — Idiomatic Kotlin: +1. Primary constructor with `id` as a `val` property. Apply both `@field:JsonProperty` + and `@get:JsonProperty` to match both Java annotation targets. +2. Convert `nickname` getter/setter → Kotlin property with `@field:Nullable` and + `@get:Nullable`. +3. Drop `Objects.requireNonNull` — Kotlin's type system enforces non-null. +4. Preserve all imports even if now unused (invariant 3). + +```kotlin +package com.acme.model + +import com.fasterxml.jackson.annotation.JsonProperty +import javax.annotation.Nullable +import java.util.Objects + +/** +* Domain model for a user with a required identifier and an optional nickname. +* +* The `id` is serialized as `"id"` and is required. +* The `nickname` may be absent. +* +* @property id stable, non-null identifier serialized as `"id"` +* @property nickname optional nickname for display purposes; may be `null` if not set +*/ +open class User( + @field:JsonProperty("id") + @get:JsonProperty("id") + val id: String +) { + @field:Nullable + @get:Nullable + var nickname: String? = null +} +``` + +Invariants check: All OK. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/KNOWN-ISSUES.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/KNOWN-ISSUES.md new file mode 100644 index 0000000..9de639d --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/KNOWN-ISSUES.md @@ -0,0 +1,358 @@ +# Known Issues and Common Pitfalls + +A reference of common issues encountered during Java-to-Kotlin conversion, with solutions. + +### Kotlin Keyword Conflicts + +Java identifiers that are reserved keywords in Kotlin will cause compilation errors after conversion. + +**Affected keywords:** `when`, `in`, `is`, `object`, `fun`, `val`, `var`, `typealias`, `as` + +**Solution:** Backtick-escape them in Kotlin: + +```java +// Java +public void when(String event) { ... } +public boolean in(List items) { ... } +``` + +```kotlin +// Kotlin — backtick-escaped +fun `when`(event: String) { ... } +fun `in`(items: List): Boolean { ... } +``` + +When the API is internal (not exposed to other modules), prefer renaming the identifier to a non-keyword alternative instead of using backticks. For example, rename `when` to `onEvent` or `in` to `contains`. + +### SAM Conversion Ambiguity + +When a Java method has overloads that each accept a different SAM (Single Abstract Method) interface, Kotlin's trailing lambda syntax becomes ambiguous. The compiler cannot determine which SAM interface the lambda should implement. + +```java +// Java — overloaded method accepting different SAM types +public class TaskExecutor { + void submit(Runnable task) { ... } + void submit(Callable task) { ... } +} +``` + +```kotlin +// Kotlin — WRONG: ambiguous, won't compile +executor.submit { doWork() } + +// Kotlin — CORRECT: explicit SAM constructor +executor.submit(Runnable { doWork() }) +executor.submit(Callable { computeResult() }) +``` + +Use explicit SAM constructor calls whenever there are overloaded methods accepting different functional interfaces. + +### Platform Types + +Java types without nullability annotations (`@Nullable`, `@NotNull`, `@NonNull`) become "platform types" (`T!`) in Kotlin. Platform types bypass Kotlin's null-safety system — they are neither nullable nor non-null, and null checks are deferred to runtime. + +```java +// Java — no nullability annotations +public String getName() { return name; } +public List getItems() { return items; } +``` + +```kotlin +// Kotlin — BAD: platform types left in converted code +val name = obj.name // inferred as String! — unsafe +val items = obj.items // inferred as List! — unsafe + +// Kotlin — GOOD: explicit nullability based on code analysis +val name: String = obj.name // if provably non-null +val name: String? = obj.name // if could be null +val items: List = obj.items // if neither list nor elements are null +``` + +Always add explicit type declarations to eliminate platform types. Analyze the Java source code, documentation, and call sites to determine the correct nullability. + +### @JvmStatic / @JvmField / @JvmOverloads + +When converted Kotlin code is still called from Java, use JVM interop annotations to maintain a clean Java API: + +**`@JvmStatic`** — Makes companion object functions accessible as static methods from Java: + +```kotlin +class Config { + companion object { + @JvmStatic + fun getInstance(): Config = ... + } +} +``` + +```java +// Java callers can use: Config.getInstance() +// Without @JvmStatic they would need: Config.Companion.getInstance() +``` + +**`@JvmField`** — Exposes a property as a direct field rather than through getter/setter: + +```kotlin +class Constants { + companion object { + @JvmField + val DEFAULT_TIMEOUT = 30_000L + } +} +``` + +```java +// Java callers can use: Constants.DEFAULT_TIMEOUT +// Without @JvmField they would need: Constants.Companion.getDEFAULT_TIMEOUT() +``` + +**`@JvmOverloads`** — Generates Java overloads for functions with default parameters: + +```kotlin +@JvmOverloads +fun connect(host: String, port: Int = 443, secure: Boolean = true) { ... } +``` + +```java +// Java sees three overloads: +// connect(String host) +// connect(String host, int port) +// connect(String host, int port, boolean secure) +``` + +### Checked Exceptions + +Kotlin does not have checked exceptions. When Kotlin code is called from Java, the Java compiler will not know about thrown exceptions unless annotated with `@Throws`: + +```kotlin +// Without @Throws, Java callers cannot catch IOException in a catch block +// (the Java compiler will say "exception is never thrown in the corresponding try block") + +@Throws(IOException::class) +fun readFile(path: String): String { + return File(path).readText() +} +``` + +Add `@Throws` to every Kotlin function that throws checked exceptions and is called from Java code. + +### Wildcard Generics + +Java wildcard types map to Kotlin's variance annotations: + +| Java | Kotlin | Description | +|------|--------|-------------| +| `? extends T` | `out T` | Covariance (producer) | +| `? super T` | `in T` | Contravariance (consumer) | +| Raw type `List` | `List` | Add explicit type parameter | + +```java +// Java +public void process(List numbers) { ... } +public void addAll(List target) { ... } +public void legacy(List items) { ... } // raw type +``` + +```kotlin +// Kotlin +fun process(numbers: List) { ... } +fun addAll(target: MutableList) { ... } +fun legacy(items: List) { ... } // explicit type parameter +``` + +For raw types, analyze the code to determine the most specific type parameter rather than defaulting to `Any?`. + +### Static Members + +Java's `static` keyword has no direct equivalent in Kotlin. Use the following mappings: + +**Static methods** — Use companion object functions, or top-level functions if they don't need class state: + +```java +// Java +public class StringUtils { + public static String capitalize(String s) { ... } +} +``` + +```kotlin +// Kotlin — top-level function (preferred when no class state needed) +fun capitalize(s: String): String { ... } + +// Kotlin — companion object (when logically tied to the class) +class StringUtils { + companion object { + fun capitalize(s: String): String { ... } + } +} +``` + +**Static constants** — Use `const val` for compile-time constants (primitives and String), `val` for object constants: + +```kotlin +class HttpStatus { + companion object { + const val OK = 200 // primitive — const val + const val NOT_FOUND_MESSAGE = "Not Found" // String — const val + val DEFAULT_HEADERS = mapOf("Accept" to "application/json") // object — val + } +} +``` + +**Static initializers** — Use companion object `init {}` block or top-level code: + +```kotlin +class Registry { + companion object { + private val handlers = mutableMapOf() + init { + handlers["default"] = DefaultHandler() + } + } +} +``` + +### Synchronized Blocks + +Java's `synchronized` constructs map to Kotlin as follows: + +**Synchronized blocks** — Use Kotlin's `synchronized()` function: + +```java +// Java +synchronized (lock) { + sharedState.update(); +} +``` + +```kotlin +// Kotlin +synchronized(lock) { + sharedState.update() +} +``` + +**Synchronized methods** — Use the `@Synchronized` annotation: + +```java +// Java +public synchronized void update() { ... } +``` + +```kotlin +// Kotlin +@Synchronized +fun update() { ... } +``` + +### Anonymous Inner Classes + +**Single Abstract Method (SAM) interfaces** — Convert to lambda syntax: + +```java +// Java +executor.submit(new Runnable() { + @Override + public void run() { + doWork(); + } +}); +``` + +```kotlin +// Kotlin +executor.submit(Runnable { doWork() }) +``` + +**Multiple methods or abstract classes** — Use `object` expression: + +```java +// Java +view.addListener(new ViewListener() { + @Override + public void onOpen() { ... } + @Override + public void onClose() { ... } +}); +``` + +```kotlin +// Kotlin +view.addListener(object : ViewListener { + override fun onOpen() { ... } + override fun onClose() { ... } +}) +``` + +### Array Handling + +Java arrays map to Kotlin types as follows: + +| Java | Kotlin | Notes | +|------|--------|-------| +| `String[]` | `Array` | Reference type arrays | +| `int[]` | `IntArray` | Primitive array (not `Array`) | +| `long[]` | `LongArray` | Primitive array | +| `double[]` | `DoubleArray` | Primitive array | +| `boolean[]` | `BooleanArray` | Primitive array | +| `Object[]` | `Array` | | +| `new int[10]` | `IntArray(10)` | Array creation | +| `new String[10]` | `arrayOfNulls(10)` | Nullable element array | +| `String... args` | `vararg args: String` | Varargs parameter | + +Using `Array` instead of `IntArray` causes boxing overhead — always use the specialized primitive array types. + +### Ternary Operator + +Kotlin has no ternary operator. Use `if`/`else` as an expression: + +```java +// Java +String label = (count > 0) ? "Items: " + count : "Empty"; +``` + +```kotlin +// Kotlin +val label = if (count > 0) "Items: $count" else "Empty" +``` + +### instanceof + +Java's `instanceof` maps to Kotlin's `is` keyword. Kotlin supports smart casting, so an explicit cast after an `is` check is unnecessary: + +```java +// Java +if (shape instanceof Circle) { + Circle circle = (Circle) shape; + double area = circle.getArea(); +} +``` + +```kotlin +// Kotlin — smart cast, no explicit cast needed +if (shape is Circle) { + val area = shape.area // shape is automatically cast to Circle +} +``` + +### try-with-resources + +Java's try-with-resources maps to Kotlin's `.use {}` extension function: + +```java +// Java +try (BufferedReader reader = new BufferedReader(new FileReader(path))) { + String line = reader.readLine(); + process(line); +} +``` + +```kotlin +// Kotlin +BufferedReader(FileReader(path)).use { reader -> + val line = reader.readLine() + process(line) +} +``` + +The `.use {}` function works on any `Closeable` or `AutoCloseable` instance and guarantees the resource is closed even if an exception is thrown. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/DAGGER-HILT.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/DAGGER-HILT.md new file mode 100644 index 0000000..fe3636b --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/DAGGER-HILT.md @@ -0,0 +1,160 @@ +# Dagger / Hilt Conversion Guide + +## When This Applies + +This guide applies when the Java source contains imports matching `dagger.*` or +`dagger.hilt.*`. This covers Dagger 2, Hilt for Android, and Hilt Jetpack integrations. + +## Key Rules + +### 1. @Inject constructor syntax + +Kotlin places `@Inject` before the `constructor` keyword in the primary constructor: + +```kotlin +class Foo @Inject constructor(private val bar: Bar) +``` + +### 2. @Module classes with @Provides methods + +Keep `@Provides` methods `open`, or use `object` for modules that contain only +`@JvmStatic` provides methods (companion object pattern): + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build() +} +``` + +### 3. @Binds abstract methods + +`@Binds` methods work in abstract classes exactly as in Java. Convert the abstract +class directly — no special Kotlin considerations. + +### 4. Hilt Android annotations + +`@HiltAndroidApp`, `@AndroidEntryPoint`, `@HiltViewModel` — preserve these exactly +on Application, Activity, Fragment, and ViewModel classes. + +### 5. Scoping annotations + +`@Singleton`, `@ActivityScoped`, `@ViewModelScoped`, `@FragmentScoped` — preserve +exactly. No annotation site target is needed. + +### 6. @AssistedInject / @AssistedFactory + +`@AssistedInject` replaces `@Inject` on the constructor. `@Assisted` parameters +appear alongside regular injected parameters in the primary constructor: + +```kotlin +class PlayerViewModel @AssistedInject constructor( + @Assisted private val playerId: String, + private val repository: PlayerRepository +) : ViewModel() +``` + +### 7. @Component / @Subcomponent interfaces + +Convert directly to Kotlin interfaces. Dagger's annotation processing works +identically with Kotlin interfaces via kapt or KSP. + +--- + +## Examples + +### Example 1: Hilt ViewModel with @Inject Constructor and a @Module + +**Java:** + +```java +package com.acme.feature; + +import androidx.lifecycle.ViewModel; +import dagger.Module; +import dagger.Provides; +import dagger.hilt.InstallIn; +import dagger.hilt.android.lifecycle.HiltViewModel; +import dagger.hilt.components.SingletonComponent; +import javax.inject.Inject; +import javax.inject.Singleton; + +@HiltViewModel +public class UserProfileViewModel extends ViewModel { + + private final UserRepository userRepository; + private final AnalyticsTracker analyticsTracker; + + @Inject + public UserProfileViewModel(UserRepository userRepository, AnalyticsTracker analyticsTracker) { + this.userRepository = userRepository; + this.analyticsTracker = analyticsTracker; + } + + public LiveData getUser(String userId) { + analyticsTracker.trackProfileView(userId); + return userRepository.getUser(userId); + } +} + +@Module +@InstallIn(SingletonComponent.class) +public class AnalyticsModule { + + @Provides + @Singleton + public AnalyticsTracker provideAnalyticsTracker(Application app) { + return new AnalyticsTracker(app); + } +} +``` + +**Kotlin:** + +```kotlin +package com.acme.feature + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Singleton + +@HiltViewModel +class UserProfileViewModel @Inject constructor( + private val userRepository: UserRepository, + private val analyticsTracker: AnalyticsTracker +) : ViewModel() { + + fun getUser(userId: String): LiveData { + analyticsTracker.trackProfileView(userId) + return userRepository.getUser(userId) + } +} + +@Module +@InstallIn(SingletonComponent::class) +object AnalyticsModule { + + @Provides + @Singleton + fun provideAnalyticsTracker(app: Application): AnalyticsTracker { + return AnalyticsTracker(app) + } +} +``` + +Key changes: +- `@Inject` moves before the `constructor` keyword in the primary constructor. +- Constructor parameters become `private val` in the primary constructor. +- The module class becomes an `object` since it contains only static-like provides methods. +- `SingletonComponent.class` becomes `SingletonComponent::class` (Kotlin class reference). +- Java getter method `getUser` becomes a regular function `getUser` (no `get` prefix + convention change needed here since it takes a parameter). diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/GUICE.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/GUICE.md new file mode 100644 index 0000000..11a795d --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/GUICE.md @@ -0,0 +1,165 @@ +# Guice Conversion Guide + +## When This Applies + +This guide applies when the Java source contains imports matching `com.google.inject.*`. +This covers Google Guice core, Guice multibindings, and Guice servlet. + +## Key Rules + +### 1. @Inject constructor syntax + +Kotlin places `@Inject` before the `constructor` keyword in the primary constructor: + +```kotlin +class Foo @Inject constructor(private val bar: Bar) +``` + +### 2. @Provides methods in Modules + +Keep `@Provides` methods as regular functions. Guice modules extend `AbstractModule`, +so override `configure()` as usual. + +### 3. Module.configure() override + +Override `configure()` in Kotlin. Use Guice's binding DSL with Kotlin class references: + +```kotlin +bind(Foo::class.java).to(FooImpl::class.java) +``` + +### 4. @Named qualifier — annotation site targets + +In Kotlin, `@Named` on constructor parameters needs a site target to reach the +parameter (not the field or property). Use `@param:Named` for constructor injection: + +```kotlin +class Foo @Inject constructor( + @param:Named("primary") private val dataSource: DataSource +) +``` + +When used on function parameters (e.g., in `@Provides` methods), no site target +is needed. + +### 5. @Singleton scope + +Preserve `@Singleton` exactly. It can be placed on the class declaration or in +module bindings via `.in(Singleton::class.java)`. + +### 6. Provider + +`Provider` can stay as-is for lazy or scoped injection. Where the only purpose +is deferred initialization, Kotlin's `lazy` delegation can be used as an alternative +outside of Guice-managed contexts. + +--- + +## Examples + +### Example 1: Guice Module with Bindings and an Injected Class + +**Java:** + +```java +package com.acme.config; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Singleton; +import com.google.inject.name.Named; + +public class AppModule extends AbstractModule { + + @Override + protected void configure() { + bind(CacheService.class).to(RedisCacheService.class); + bind(NotificationService.class).to(EmailNotificationService.class).in(Singleton.class); + } + + @Provides + @Singleton + public HttpClient provideHttpClient(@Named("baseUrl") String baseUrl) { + return new HttpClient(baseUrl); + } +} +``` + +```java +package com.acme.service; + +import com.google.inject.Inject; +import com.google.inject.name.Named; + +public class OrderService { + + private final CacheService cacheService; + private final HttpClient httpClient; + private final String region; + + @Inject + public OrderService(CacheService cacheService, HttpClient httpClient, @Named("region") String region) { + this.cacheService = cacheService; + this.httpClient = httpClient; + this.region = region; + } + + public Order findById(Long id) { + return cacheService.getOrFetch(id, () -> httpClient.get("/orders/" + id, Order.class)); + } +} +``` + +**Kotlin:** + +```kotlin +package com.acme.config + +import com.google.inject.AbstractModule +import com.google.inject.Provides +import com.google.inject.Singleton +import com.google.inject.name.Named + +class AppModule : AbstractModule() { + + override fun configure() { + bind(CacheService::class.java).to(RedisCacheService::class.java) + bind(NotificationService::class.java).to(EmailNotificationService::class.java).`in`(Singleton::class.java) + } + + @Provides + @Singleton + fun provideHttpClient(@Named("baseUrl") baseUrl: String): HttpClient { + return HttpClient(baseUrl) + } +} +``` + +```kotlin +package com.acme.service + +import com.google.inject.Inject +import com.google.inject.name.Named + +class OrderService @Inject constructor( + private val cacheService: CacheService, + private val httpClient: HttpClient, + @param:Named("region") private val region: String +) { + + fun findById(id: Long): Order? { + return cacheService.getOrFetch(id) { httpClient.get("/orders/$id", Order::class.java) } + } +} +``` + +Key changes: +- `@Inject` moves before the `constructor` keyword in the primary constructor. +- Constructor parameters become `private val` in the primary constructor. +- `@Named("region")` uses `@param:Named` site target so the annotation reaches the + constructor parameter rather than the Kotlin property. +- `.in(Singleton.class)` becomes `` .`in`(Singleton::class.java) `` — `in` is a + reserved keyword in Kotlin and must be escaped with backticks. +- The lambda in `getOrFetch` uses Kotlin's trailing lambda syntax instead of an + anonymous inner class. +- String concatenation `"/orders/" + id` becomes a string template `"/orders/$id"`. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/HIBERNATE.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/HIBERNATE.md new file mode 100644 index 0000000..014228d --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/HIBERNATE.md @@ -0,0 +1,227 @@ +# Hibernate / JPA Conversion Guide + +## When This Applies + +Detected when imports match any of: +- `javax.persistence.*` +- `jakarta.persistence.*` +- `org.hibernate.*` + +## Critical Rules + +1. **Do NOT use data classes for JPA entities.** Data classes generate `equals`/`hashCode` + based on all properties, which breaks Hibernate's identity semantics and proxy creation. + +2. **Keep entity classes `open`.** Hibernate creates proxies via subclassing. Kotlin classes + are `final` by default, so you must use `open` explicitly (or use the `allopen` compiler + plugin with JPA annotation support). + +3. **Provide a no-argument constructor** if Hibernate requires one for proxy creation. Use a + secondary constructor or default values for all primary constructor parameters. + +4. **Annotation site targets matter:** + - `@Id`, `@Column`, `@GeneratedValue` on fields → use `@field:Id`, `@field:Column`, etc. + in Kotlin, OR place annotations on constructor parameters with `@field:` site target. + - `@ManyToOne`, `@OneToMany`, `@JoinColumn` → same `@field:` targeting. + +5. **Lazy loading considerations:** `@ManyToOne(fetch = FetchType.LAZY)` requires the entity + class to be open for proxy creation. `@OneToMany` with lazy collections work with Kotlin's + `MutableList`. + +6. **`@Embeddable` classes**: Can be data classes (they don't need proxies). + +7. **`@MappedSuperclass`**: Must be `open abstract class` in Kotlin. + +## Examples + +### Example 1: JPA Entity with @Id, @Column, and Relationships + +**Java:** +```java +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "username", nullable = false, unique = true) + private String username; + + @Column(name = "email") + private String email; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "department_id") + private Department department; + + protected User() {} + + public User(String username, String email, Department department) { + this.username = username; + this.email = email; + this.department = department; + } + + public Long getId() { return id; } + public String getUsername() { return username; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public Department getDepartment() { return department; } + public void setDepartment(Department department) { this.department = department; } +} +``` + +**Kotlin:** +```kotlin +@Entity +@Table(name = "users") +open class User( + + @field:Column(name = "username", nullable = false, unique = true) + open val username: String, + + @field:Column(name = "email") + open var email: String? = null, + + @field:ManyToOne(fetch = FetchType.LAZY) + @field:JoinColumn(name = "department_id") + open var department: Department? = null + +) { + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + open var id: Long? = null + protected set + + protected constructor() : this(username = "") +} +``` + +### Example 2: @Embeddable Value Object + +**Java:** +```java +@Embeddable +public class Address { + + @Column(name = "street") + private String street; + + @Column(name = "city") + private String city; + + @Column(name = "zip_code") + private String zipCode; + + protected Address() {} + + public Address(String street, String city, String zipCode) { + this.street = street; + this.city = city; + this.zipCode = zipCode; + } + + public String getStreet() { return street; } + public String getCity() { return city; } + public String getZipCode() { return zipCode; } +} +``` + +**Kotlin:** +```kotlin +@Embeddable +data class Address( + + @field:Column(name = "street") + val street: String = "", + + @field:Column(name = "city") + val city: String = "", + + @field:Column(name = "zip_code") + val zipCode: String = "" +) +``` + +`@Embeddable` classes can safely be data classes because Hibernate does not proxy them. +Default values satisfy the no-arg constructor requirement. + +### Example 3: Entity with @ManyToOne and @OneToMany + +**Java:** +```java +@Entity +@Table(name = "departments") +public class Department { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @OneToMany(mappedBy = "department", cascade = CascadeType.ALL, orphanRemoval = true) + private List users = new ArrayList<>(); + + protected Department() {} + + public Department(String name) { + this.name = name; + } + + public Long getId() { return id; } + public String getName() { return name; } + public List getUsers() { return users; } + + public void addUser(User user) { + users.add(user); + user.setDepartment(this); + } + + public void removeUser(User user) { + users.remove(user); + user.setDepartment(null); + } +} +``` + +**Kotlin:** +```kotlin +@Entity +@Table(name = "departments") +open class Department( + + @field:Column(name = "name", nullable = false) + open val name: String = "" + +) { + @field:Id + @field:GeneratedValue(strategy = GenerationType.IDENTITY) + open var id: Long? = null + protected set + + @field:OneToMany(mappedBy = "department", cascade = [CascadeType.ALL], orphanRemoval = true) + open val users: MutableList = mutableListOf() + + protected constructor() : this(name = "") + + fun addUser(user: User) { + users.add(user) + user.department = this + } + + fun removeUser(user: User) { + users.remove(user) + user.department = null + } +} +``` + +Key points in this example: +- `cascade` array syntax uses Kotlin's `[CascadeType.ALL]` instead of Java's `{CascadeType.ALL}`. +- The collection is typed as `MutableList` to allow Hibernate to manage the relationship. +- The class and its properties are `open` so Hibernate can create proxies. +- The no-arg constructor delegates to the primary constructor with default values. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/JACKSON.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/JACKSON.md new file mode 100644 index 0000000..aa7b8f1 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/JACKSON.md @@ -0,0 +1,248 @@ +# Jackson Conversion Guide + +## When This Applies + +Detected when imports match `com.fasterxml.jackson.*`. + +## Key Rules + +1. **Annotation site targets**: + - `@JsonProperty` on a Java field → `@field:JsonProperty` in Kotlin. + - `@JsonProperty` on a Java getter → `@get:JsonProperty` in Kotlin. + - When converting to Kotlin properties, apply BOTH `@field:` and `@get:` targets to + match Java's dual annotation on field + getter. + +2. **@JsonCreator**: Java's `@JsonCreator` static factory or constructor → Kotlin primary + constructor. The `@JsonCreator` annotation is often unnecessary on Kotlin's primary + constructor if using the Jackson Kotlin module, but preserve it for safety. + +3. **@JsonIgnore**: Preserve exactly. Use `@get:JsonIgnore` or `@field:JsonIgnore` + depending on original target. + +4. **@JsonDeserialize / @JsonSerialize**: Preserve exactly with correct site targets. + +5. **@JsonInclude**: Preserve on class or property level. + +6. **@JsonFormat**: Preserve with `@field:JsonFormat` site target. + +7. **Jackson Kotlin Module**: Note that projects using Jackson with Kotlin should add + `jackson-module-kotlin` for proper Kotlin support (data classes, default values, + nullable types). This is NOT something to add during conversion — just note it if + missing. + +8. **Builder pattern with @JsonPOJOBuilder**: Replace with primary constructor + + `@JsonCreator` if converting to data class. Otherwise preserve. + +--- + +## Example 1: DTO with Various Jackson Annotations + +### Java Input + +```java +package com.acme.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** + * Data transfer object for an order summary. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OrderSummaryDto { + + @JsonProperty("order_id") + private final String orderId; + + @JsonProperty("total_amount") + private final double totalAmount; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + private final String createdDate; + + @JsonIgnore + private String internalNote; + + public OrderSummaryDto(String orderId, double totalAmount, String createdDate) { + this.orderId = orderId; + this.totalAmount = totalAmount; + this.createdDate = createdDate; + } + + @JsonProperty("order_id") + public String getOrderId() { + return orderId; + } + + @JsonProperty("total_amount") + public double getTotalAmount() { + return totalAmount; + } + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + public String getCreatedDate() { + return createdDate; + } + + @JsonIgnore + public String getInternalNote() { + return internalNote; + } + + public void setInternalNote(String internalNote) { + this.internalNote = internalNote; + } +} +``` + +### Kotlin Output + +```kotlin +package com.acme.dto + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonFormat + +/** + * Data transfer object for an order summary. + * + * @property orderId unique identifier for the order, serialized as `"order_id"` + * @property totalAmount total monetary amount, serialized as `"total_amount"` + * @property createdDate date the order was created, formatted as `yyyy-MM-dd` + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +open class OrderSummaryDto( + @field:JsonProperty("order_id") + @get:JsonProperty("order_id") + val orderId: String?, + + @field:JsonProperty("total_amount") + @get:JsonProperty("total_amount") + val totalAmount: Double, + + @field:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + @get:JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + val createdDate: String? +) { + @field:JsonIgnore + @get:JsonIgnore + var internalNote: String? = null +} +``` + +**Key points:** +- `@JsonInclude` stays at class level — no site target needed. +- `@JsonProperty` gets both `@field:` and `@get:` to match the Java field + getter + annotations. +- `@JsonFormat` also gets both `@field:` and `@get:` since Java had it on both. +- `@JsonIgnore` gets both `@field:` and `@get:` to suppress serialization fully. + +--- + +## Example 2: Class with @JsonCreator Factory Method + +### Java Input + +```java +package com.acme.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Immutable configuration entry deserialized from JSON. + */ +public class ConfigEntry { + + private final String key; + private final String value; + private final boolean enabled; + + @JsonCreator + public static ConfigEntry create( + @JsonProperty("key") String key, + @JsonProperty("value") String value, + @JsonProperty("enabled") boolean enabled) { + return new ConfigEntry(key, value, enabled); + } + + private ConfigEntry(String key, String value, boolean enabled) { + this.key = key; + this.value = value; + this.enabled = enabled; + } + + @JsonProperty("key") + public String getKey() { + return key; + } + + @JsonProperty("value") + public String getValue() { + return value; + } + + @JsonProperty("enabled") + public boolean isEnabled() { + return enabled; + } +} +``` + +### Kotlin Output + +```kotlin +package com.acme.model + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Immutable configuration entry deserialized from JSON. + * + * @property key the configuration key + * @property value the configuration value + * @property enabled whether this entry is active + */ +data class ConfigEntry @JsonCreator constructor( + @field:JsonProperty("key") + @get:JsonProperty("key") + val key: String?, + + @field:JsonProperty("value") + @get:JsonProperty("value") + val value: String?, + + @field:JsonProperty("enabled") + @get:JsonProperty("enabled") + val enabled: Boolean +) { + companion object { + /** + * Factory method preserved for documentation; the primary constructor + * with [JsonCreator] handles deserialization directly. + */ + @JsonCreator + @JvmStatic + fun create( + @JsonProperty("key") key: String?, + @JsonProperty("value") value: String?, + @JsonProperty("enabled") enabled: Boolean + ): ConfigEntry = ConfigEntry(key, value, enabled) + } +} +``` + +**Key points:** +- The Java `@JsonCreator` static factory is converted to a Kotlin primary constructor + with `@JsonCreator`. The companion object factory is preserved for backward + compatibility but the primary constructor handles deserialization. +- The class becomes a `data class` since it is immutable and value-oriented. +- `@JsonCreator` is kept on the primary constructor for safety, ensuring Jackson can + deserialize even without the Jackson Kotlin module. +- String parameters remain nullable (`String?`) since Java strings are nullable by + default and there is no `@NonNull` or `Objects.requireNonNull` evidence. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/JUNIT.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/JUNIT.md new file mode 100644 index 0000000..fb4a286 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/JUNIT.md @@ -0,0 +1,193 @@ +# JUnit / TestNG Conversion Guide + +## When This Applies + +Detected when imports match `org.junit.*` or `org.testng.*`. + +## Key Rules + +### 1. JUnit 4 to Kotlin (with JUnit 5) + +| JUnit 4 | Kotlin (JUnit 5 / kotlin.test) | +|---|---| +| `@Test` | `@Test` (from `kotlin.test` or `org.junit.jupiter.api`) | +| `@Before` | `@BeforeEach` (JUnit 5) or `@BeforeTest` (kotlin.test) | +| `@After` | `@AfterEach` (JUnit 5) or `@AfterTest` (kotlin.test) | +| `@BeforeClass` | `@BeforeAll` in companion object with `@JvmStatic` | +| `@AfterClass` | `@AfterAll` in companion object with `@JvmStatic` | +| `@RunWith` | `@ExtendWith` (JUnit 5) | +| `@Ignore` | `@Disabled` (JUnit 5) | +| `@Rule` / `@ClassRule` | `@ExtendWith` or `@RegisterExtension` | +| `Assert.assertEquals(expected, actual)` | `assertEquals(expected, actual)` (kotlin.test) | +| `Assert.assertTrue(condition)` | `assertTrue(condition)` (kotlin.test) | +| `@Test(expected = X.class)` | `assertFailsWith { }` (kotlin.test) or `assertThrows { }` (JUnit 5) | + +### 2. JUnit 5 stays mostly the same + +JUnit 5 annotations (`@Test`, `@BeforeEach`, `@AfterEach`, etc.) remain unchanged. +Focus on Kotlin idioms in the test body: + +- `assertThrows { code }` — uses reified generics, no `.class` needed. +- Test classes and methods do not need to be `public` — Kotlin's default visibility + is public, which satisfies JUnit's requirements. +- Test methods do not need `open` unless using a framework that subclasses the test + (e.g., certain Spring test configurations). + +### 3. TestNG to Kotlin + +| TestNG | Kotlin (JUnit 5) | +|---|---| +| `@Test` | `@Test` | +| `@BeforeMethod` | `@BeforeEach` | +| `@AfterMethod` | `@AfterEach` | +| `@BeforeClass` | `@BeforeAll` with `@JvmStatic` in companion object | +| `@AfterClass` | `@AfterAll` with `@JvmStatic` in companion object | +| `@DataProvider` | `@ParameterizedTest` + `@MethodSource` | + +### 4. Assertion style + +Prefer `kotlin.test` assertions (`assertEquals`, `assertTrue`, `assertFailsWith`) +for portability across test frameworks. They delegate to the underlying framework +at runtime. + +### 5. Backtick method names + +Kotlin allows backtick-quoted method names for readable test names: +```kotlin +@Test +fun `should return empty list when no users exist`() { ... } +``` + +--- + +## Example: JUnit 4 Test Class to Kotlin with JUnit 5 + +### Java Input + +```java +package com.acme.service; + +import org.junit.Before; +import org.junit.After; +import org.junit.Test; +import org.junit.BeforeClass; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests for the UserService class. + */ +public class UserServiceTest { + + private static DatabaseConnection db; + private UserService userService; + + @BeforeClass + public static void setupDatabase() { + db = DatabaseConnection.create("test"); + } + + @Before + public void setUp() { + userService = new UserService(db); + } + + @After + public void tearDown() { + db.clearTestData(); + } + + @Test + public void testFindById() { + User user = userService.findById(1L); + assertNotNull(user); + assertEquals("Alice", user.getName()); + } + + @Test + public void testFindAllReturnsNonEmptyList() { + List users = userService.findAll(); + assertNotNull(users); + assertTrue(users.size() > 0); + } + + @Test(expected = IllegalArgumentException.class) + public void testFindByIdWithNegativeIdThrows() { + userService.findById(-1L); + } +} +``` + +### Kotlin Output + +```kotlin +package com.acme.service + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for the UserService class. + */ +class UserServiceTest { + + companion object { + private lateinit var db: DatabaseConnection + + @BeforeAll + @JvmStatic + fun setupDatabase() { + db = DatabaseConnection.create("test") + } + } + + private lateinit var userService: UserService + + @BeforeEach + fun setUp() { + userService = UserService(db) + } + + @AfterEach + fun tearDown() { + db.clearTestData() + } + + @Test + fun `should find user by id`() { + val user = userService.findById(1L) + assertNotNull(user) + assertEquals("Alice", user.name) + } + + @Test + fun `should return non-empty list from findAll`() { + val users = userService.findAll() + assertNotNull(users) + assertTrue(users.isNotEmpty()) + } + + @Test + fun `should throw IllegalArgumentException for negative id`() { + assertFailsWith { + userService.findById(-1L) + } + } +} +``` + +**Key points:** +- JUnit 4 `@Before` / `@After` → JUnit 5 `@BeforeEach` / `@AfterEach`. +- `@BeforeClass` static method → `@BeforeAll` + `@JvmStatic` inside `companion object`. +- `@Test(expected = ...)` → `assertFailsWith { }` with reified generics. +- Static assertions become kotlin.test top-level function imports. +- Test method names use backtick syntax for readability. +- `users.size() > 0` becomes idiomatic `users.isNotEmpty()`. +- The `db` field uses `lateinit var` since it is initialized in `@BeforeAll`. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/LOMBOK.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/LOMBOK.md new file mode 100644 index 0000000..c3546a0 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/LOMBOK.md @@ -0,0 +1,237 @@ +# Lombok Conversion Guide + +## When This Applies + +Detected when imports match `lombok.*`. + +## Core Rule + +**Remove ALL Lombok annotations entirely.** Do not convert Lombok to Lombok — convert +to idiomatic Kotlin equivalents. Lombok has no place in Kotlin code. + +## Annotation Conversion Table + +| Lombok Annotation | Kotlin Equivalent | +|---|---| +| `@Getter` / `@Setter` | Kotlin properties (val/var) — automatic | +| `@Data` | `data class` with primary constructor properties | +| `@Value` (Lombok) | `data class` with `val` properties (immutable) | +| `@Builder` | Default parameter values, or named arguments. For complex builders, use Kotlin builder DSL | +| `@NoArgsConstructor` | Secondary no-arg constructor, or default values for all params | +| `@AllArgsConstructor` | Primary constructor (Kotlin default) | +| `@RequiredArgsConstructor` | Primary constructor with only required (non-default) params | +| `@ToString` | `data class` auto-generates toString, or manual `override fun toString()` | +| `@EqualsAndHashCode` | `data class` auto-generates, or manual `override fun equals/hashCode` | +| `@Slf4j` / `@Log` / `@Log4j2` | Companion object with logger (see example below) | +| `@Cleanup` | Kotlin's `.use {}` extension function | +| `@SneakyThrows` | Kotlin has no checked exceptions — just remove it | +| `@Synchronized` | Kotlin's `@Synchronized` annotation | +| `@With` | `data class` `.copy()` method | +| `@Accessors(chain = true)` | Kotlin's `apply {}` block | + +## Key Rules + +1. **@Slf4j** — Convert to a companion object with an explicit logger: +```kotlin +companion object { + private val log = LoggerFactory.getLogger(MyClass::class.java) +} +``` + +2. **@Data with JPA entities** — Do NOT use `data class` for JPA entities. Use regular + `open class` with properties instead. Data classes break Hibernate proxies. + +3. **@Builder** — Prefer default parameter values. Only create an explicit builder + pattern if the Java code has complex builder logic beyond simple setters. + +4. **Lombok `val`** — Replace with Kotlin's `val` (they serve the same purpose). + +--- + +## Example 1: @Data Class with @Builder + +### Java Input + +```java +package com.acme.model; + +import lombok.Builder; +import lombok.Data; + +/** + * Represents a customer order with shipping details. + */ +@Data +@Builder +public class Order { + private String orderId; + private String customerName; + private int quantity; + private boolean expedited; +} +``` + +### Kotlin Output + +```kotlin +package com.acme.model + +/** + * Represents a customer order with shipping details. + */ +data class Order( + val orderId: String?, + val customerName: String?, + val quantity: Int = 0, + val expedited: Boolean = false +) +``` + +**What changed:** +- `@Data` → `data class` with primary constructor properties. +- `@Builder` → default parameter values. Callers use named arguments: + `Order(orderId = "123", customerName = "Alice", quantity = 2)`. +- All Lombok imports removed. +- Fields become `val` properties (immutable by default; use `var` only if mutation is + required by the original code). +- Reference types are nullable (`String?`) because Java fields default to `null` unless + proven otherwise. + +--- + +## Example 2: @Slf4j Annotated Service Class + +### Java Input + +```java +package com.acme.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service that processes incoming payment requests. + */ +@Slf4j +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentGateway gateway; + private final NotificationSender notifier; + + /** + * Processes a payment for the given amount. + * + * @param amount the payment amount in cents + * @return true if the payment succeeded + */ + public boolean processPayment(long amount) { + log.info("Processing payment of {} cents", amount); + try { + gateway.charge(amount); + notifier.sendConfirmation(amount); + log.info("Payment of {} cents succeeded", amount); + return true; + } catch (Exception e) { + log.error("Payment failed for amount {}", amount, e); + return false; + } + } +} +``` + +### Kotlin Output + +```kotlin +package com.acme.service + +import org.slf4j.LoggerFactory + +/** + * Service that processes incoming payment requests. + */ +open class PaymentService( + private val gateway: PaymentGateway, + private val notifier: NotificationSender +) { + + companion object { + private val log = LoggerFactory.getLogger(PaymentService::class.java) + } + + /** + * Processes a payment for the given amount. + * + * @param amount the payment amount in cents + * @return true if the payment succeeded + */ + fun processPayment(amount: Long): Boolean { + log.info("Processing payment of {} cents", amount) + return try { + gateway.charge(amount) + notifier.sendConfirmation(amount) + log.info("Payment of {} cents succeeded", amount) + true + } catch (e: Exception) { + log.error("Payment failed for amount {}", amount, e) + false + } + } +} +``` + +**What changed:** +- `@Slf4j` → companion object with `LoggerFactory.getLogger(...)`. +- `@RequiredArgsConstructor` → primary constructor with `val` parameters. +- Lombok imports replaced with `org.slf4j.LoggerFactory`. +- `try/catch` used as an expression (idiomatic Kotlin). +- Class is `open` because Java classes are implicitly open. + +--- + +## Example 3: @Value (Lombok) Immutable Class + +### Java Input + +```java +package com.acme.config; + +import lombok.Value; + +/** + * Immutable configuration for connecting to a database. + */ +@Value +public class DatabaseConfig { + String host; + int port; + String databaseName; + boolean useSsl; +} +``` + +### Kotlin Output + +```kotlin +package com.acme.config + +/** + * Immutable configuration for connecting to a database. + */ +data class DatabaseConfig( + val host: String?, + val port: Int, + val databaseName: String?, + val useSsl: Boolean +) +``` + +**What changed:** +- `@Value` → `data class` with `val` properties (all immutable). +- Lombok's `@Value` makes the class final, and Kotlin `data class` is also final by + default — so the semantics match. +- All Lombok imports removed. +- Auto-generated `equals()`, `hashCode()`, `toString()`, and `copy()` come from + `data class` for free. +- Reference types are nullable (`String?`) since the original Java fields have no + nullability annotations. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/MICRONAUT.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/MICRONAUT.md new file mode 100644 index 0000000..d46dbcd --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/MICRONAUT.md @@ -0,0 +1,120 @@ +# Micronaut Conversion Guide + +## When This Applies + +This guide applies when the Java source contains imports matching `io.micronaut.*`. +This covers Micronaut HTTP, Micronaut Data, and Micronaut Security. + +## Key Rules + +### 1. Constructor injection is the default + +Micronaut uses compile-time dependency injection via constructor injection by default. +This maps naturally to Kotlin's primary constructor. Remove `@Inject` when there is +only one constructor — Micronaut discovers it automatically. + +### 2. Stereotype annotations + +`@Singleton`, `@Controller`, `@Client`, `@Repository` — preserve these exactly. +No annotation site target is needed. + +### 3. @Value annotation + +Escape `$` in Kotlin to prevent string template interpretation: + +```kotlin +@Value("\${config.key}") val configKey: String +``` + +### 4. @Inject field injection → constructor injection + +Replace `@Inject` on fields with constructor parameters in Kotlin's primary constructor. +This eliminates `lateinit var` and makes dependencies immutable. + +### 5. AOP interceptors require open classes + +Classes using AOP annotations (`@Around`, `@Introduction`, `@Cacheable`) must be `open` +in Kotlin because Micronaut generates subclass proxies for them at compile time. + +### 6. Bean factories + +`@Factory` classes and their `@Bean`-annotated methods should be `open` so Micronaut +can manage their lifecycle through subclassing. + +### 7. @ConfigurationProperties + +Convert to a class with mutable properties. Use `lateinit var` for required `String` +properties and `var` with defaults for primitives. The class must be `open`. + +--- + +## Examples + +### Example 1: Micronaut Controller with Constructor Injection + +**Java:** + +```java +package com.acme.web; + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import jakarta.inject.Inject; + +@Controller("/api/orders") +public class OrderController { + + private final OrderService orderService; + private final InventoryClient inventoryClient; + + @Inject + public OrderController(OrderService orderService, InventoryClient inventoryClient) { + this.orderService = orderService; + this.inventoryClient = inventoryClient; + } + + @Get("/{id}") + public OrderDto getOrder(@PathVariable Long id) { + return orderService.findById(id); + } + + @Get + public List listOrders() { + return orderService.findAll(); + } +} +``` + +**Kotlin:** + +```kotlin +package com.acme.web + +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.PathVariable + +@Controller("/api/orders") +class OrderController( + private val orderService: OrderService, + private val inventoryClient: InventoryClient +) { + + @Get("/{id}") + fun getOrder(@PathVariable id: Long): OrderDto? { + return orderService.findById(id) + } + + @Get + fun listOrders(): List { + return orderService.findAll() + } +} +``` + +Key changes: +- `@Inject` is removed — Micronaut auto-discovers the single constructor. +- The `jakarta.inject.Inject` import is removed because it is no longer referenced. +- Constructor parameters become `private val` in the primary constructor. +- Return type `OrderDto` becomes `OrderDto?` where the service may return null. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/MOCKITO.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/MOCKITO.md new file mode 100644 index 0000000..337f7e3 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/MOCKITO.md @@ -0,0 +1,253 @@ +# Mockito to MockK Conversion Guide + +## When This Applies + +Detected when imports match `org.mockito.*`. + +While Mockito works in Kotlin, MockK is the idiomatic Kotlin mocking library. Convert +to MockK when the project already uses MockK or is doing a full Kotlin migration. If +the project wants to keep Mockito, convert only the Java syntax to Kotlin syntax using +the `mockito-kotlin` helper library. + +## Key Rules + +### 1. MockK conversion table + +| Mockito | MockK | +|---|---| +| `Mockito.mock(Foo.class)` | `mockk()` | +| `@Mock Foo foo` | `@MockK lateinit var foo: Foo` (with `@ExtendWith(MockKExtension::class)`) | +| `when(foo.bar()).thenReturn(x)` | `every { foo.bar() } returns x` | +| `when(foo.bar()).thenThrow(e)` | `every { foo.bar() } throws e` | +| `when(foo.bar()).thenAnswer { }` | `every { foo.bar() } answers { }` | +| `doNothing().when(foo).bar()` | `justRun { foo.bar() }` | +| `verify(foo).bar()` | `verify { foo.bar() }` | +| `verify(foo, times(2)).bar()` | `verify(exactly = 2) { foo.bar() }` | +| `verify(foo, never()).bar()` | `verify(exactly = 0) { foo.bar() }` | +| `ArgumentCaptor` | `slot()` and `capture(slot)` | +| `any()` | `any()` | +| `eq(x)` | `eq(x)` (often not needed — MockK matches exact values by default) | +| `Mockito.spy(obj)` | `spyk(obj)` | +| `@InjectMocks` | No direct equivalent — use constructor injection | +| `verifyNoMoreInteractions(foo)` | `confirmVerified(foo)` | + +### 2. Coroutine support in MockK + +For suspending functions, use `coEvery` and `coVerify` instead of `every` and `verify`: +```kotlin +coEvery { foo.suspendBar() } returns x +coVerify { foo.suspendBar() } +``` + +### 3. Keeping Mockito (syntax-only conversion) + +If keeping Mockito, use the `mockito-kotlin` library (`org.mockito.kotlin`) for +Kotlin-friendly wrappers: +- `mock()` instead of `Mockito.mock(Foo::class.java)` — uses reified generics. +- `whenever(foo.bar())` instead of `` Mockito.`when`(foo.bar()) `` — avoids backtick- + escaping `when` (it is a Kotlin keyword). +- `argumentCaptor()` — type-safe captor via reified generics. +- `any()` — properly handles Kotlin's non-null types. + +### 4. Relaxed mocks + +MockK supports relaxed mocks that return default values without explicit stubbing: +`mockk(relaxed = true)`. This has no direct Mockito equivalent (Mockito's +`RETURNS_DEFAULTS` is the closest). + +--- + +## Example 1: Converting to MockK + +### Java Input + +```java +package com.acme.service; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyLong; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +/** + * Tests for OrderService using Mockito mocks. + */ +public class OrderServiceTest { + + private UserRepository userRepository; + private OrderRepository orderRepository; + private OrderService orderService; + + @Before + public void setUp() { + userRepository = mock(UserRepository.class); + orderRepository = mock(OrderRepository.class); + orderService = new OrderService(userRepository, orderRepository); + } + + @Test + public void testCreateOrderForUser() { + User user = new User(1L, "Alice"); + when(userRepository.findById(1L)).thenReturn(user); + + orderService.createOrder(1L, "ITEM-100"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Order.class); + verify(orderRepository).save(captor.capture()); + assertEquals("ITEM-100", captor.getValue().getItemCode()); + assertEquals(1L, captor.getValue().getUserId()); + } + + @Test + public void testGetOrderCount() { + when(orderRepository.countByUserId(anyLong())).thenReturn(5); + + int count = orderService.getOrderCount(1L); + + assertEquals(5, count); + verify(orderRepository).countByUserId(1L); + } +} +``` + +### Kotlin Output (MockK) + +```kotlin +package com.acme.service + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +/** + * Tests for OrderService using MockK mocks. + */ +class OrderServiceTest { + + private val userRepository = mockk() + private val orderRepository = mockk() + private val orderService = OrderService(userRepository, orderRepository) + + @Test + fun `should create order for user`() { + val user = User(1L, "Alice") + every { userRepository.findById(1L) } returns user + every { orderRepository.save(any()) } returns Unit + + orderService.createOrder(1L, "ITEM-100") + + val orderSlot = slot() + verify { orderRepository.save(capture(orderSlot)) } + assertEquals("ITEM-100", orderSlot.captured.itemCode) + assertEquals(1L, orderSlot.captured.userId) + } + + @Test + fun `should return order count`() { + every { orderRepository.countByUserId(any()) } returns 5 + + val count = orderService.getOrderCount(1L) + + assertEquals(5, count) + verify { orderRepository.countByUserId(1L) } + } +} +``` + +**Key points:** +- `mock(Foo.class)` → `mockk()` using reified generics. +- `@Before` setUp is eliminated — mocks are initialized inline with property + declarations. This works because MockK mocks do not require a runner. +- `when(...).thenReturn(...)` → `every { ... } returns ...`. +- `ArgumentCaptor` → `slot()` with `capture(slot)`, accessed via `slot.captured`. +- `anyLong()` → `any()` (MockK's `any()` handles all types). +- `verify(foo).bar()` → `verify { foo.bar() }`. + +--- + +## Example 2: Keeping Mockito (mockito-kotlin syntax) + +### Java Input + +```java +package com.acme.service; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for PricingService using Mockito. + */ +public class PricingServiceTest { + + private PriceRepository priceRepository; + private PricingService pricingService; + + @Before + public void setUp() { + priceRepository = mock(PriceRepository.class); + pricingService = new PricingService(priceRepository); + } + + @Test + public void testGetPrice() { + when(priceRepository.findPriceByItemCode("ITEM-1")).thenReturn(9.99); + double price = pricingService.getPrice("ITEM-1"); + assertEquals(9.99, price, 0.001); + verify(priceRepository).findPriceByItemCode("ITEM-1"); + } +} +``` + +### Kotlin Output (mockito-kotlin) + +```kotlin +package com.acme.service + +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals + +/** + * Tests for PricingService using Mockito. + */ +class PricingServiceTest { + + private val priceRepository = mock() + private val pricingService = PricingService(priceRepository) + + @Test + fun `should return price for item`() { + whenever(priceRepository.findPriceByItemCode("ITEM-1")).thenReturn(9.99) + + val price = pricingService.getPrice("ITEM-1") + + assertEquals(9.99, price, 0.001) + verify(priceRepository).findPriceByItemCode("ITEM-1") + } +} +``` + +**Key points:** +- `mock(Foo.class)` → `mock()` from `org.mockito.kotlin` (reified generics). +- `when(...)` → `whenever(...)` to avoid backtick-escaping the `when` keyword. +- `verify` stays the same — `org.mockito.kotlin.verify` wraps Mockito's verify. +- The `setUp` method is eliminated — mocks are initialized inline. +- `assertEquals` with a delta parameter works the same way from kotlin.test. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/QUARKUS.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/QUARKUS.md new file mode 100644 index 0000000..453f157 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/QUARKUS.md @@ -0,0 +1,138 @@ +# Quarkus Conversion Guide + +## When This Applies + +This guide applies when the Java source contains imports matching `io.quarkus.*`, +`javax.enterprise.*`, or `jakarta.enterprise.*`. This covers Quarkus REST, Quarkus CDI, +and Panache ORM. + +## Key Rules + +### 1. CDI beans need a no-arg constructor + +The CDI specification requires beans to have a no-arg constructor (package-private or +public). In Kotlin, satisfy this by giving all constructor parameters default values, +or by adding a secondary no-arg constructor. + +### 2. Scope annotations + +`@ApplicationScoped`, `@RequestScoped`, `@Dependent` — preserve these exactly. +Beans with these scopes must have a no-arg constructor accessible to CDI. + +### 3. @Inject field injection → constructor injection + +Replace `@Inject` on fields with an `@Inject`-annotated primary constructor in Kotlin. +CDI requires the `@Inject` annotation on the constructor when multiple constructors +exist. With a single constructor, Quarkus discovers it automatically. + +### 4. REST endpoint annotations + +`@Path`, `@GET`, `@POST`, `@PUT`, `@DELETE`, `@Produces`, `@Consumes` — preserve +these exactly. No annotation site target is needed. + +### 5. Panache entities + +Panache entities must remain `open` — do NOT use `data class`. Extend `PanacheEntity` +(auto-generated Long ID) or `PanacheEntityBase` (custom ID type). Keep fields as +`open` mutable properties because Panache enhances field access at build time. + +### 6. @ConfigProperty + +Use on constructor parameters with a default value to satisfy CDI's no-arg +constructor requirement: + +```kotlin +@ConfigProperty(name = "app.greeting") val greeting: String = "" +``` + +--- + +## Examples + +### Example 1: REST Resource with CDI Injection + +**Java:** + +```java +package com.acme.web; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; + +@Path("/api/products") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +public class ProductResource { + + @Inject + ProductService productService; + + @Inject + PricingService pricingService; + + @GET + public List listProducts() { + return productService.findAll(); + } + + @GET + @Path("/{id}") + public ProductDto getProduct(@PathParam("id") Long id) { + return productService.findById(id); + } +} +``` + +**Kotlin:** + +```kotlin +package com.acme.web + +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.GET +import jakarta.ws.rs.Path +import jakarta.ws.rs.PathParam +import jakarta.ws.rs.Produces +import jakarta.ws.rs.core.MediaType + +@Path("/api/products") +@ApplicationScoped +@Produces(MediaType.APPLICATION_JSON) +class ProductResource @Inject constructor( + private val productService: ProductService, + private val pricingService: PricingService +) { + + // No-arg constructor required by CDI — default values satisfy this + constructor() : this( + productService = ProductService(), + pricingService = PricingService() + ) + + @GET + fun listProducts(): List { + return productService.findAll() + } + + @GET + @Path("/{id}") + fun getProduct(@PathParam("id") id: Long): ProductDto? { + return productService.findById(id) + } +} +``` + +Key changes: +- `@Inject` field injection is replaced by an `@Inject`-annotated primary constructor. +- A secondary no-arg constructor is added to satisfy the CDI specification. In practice, + CDI will use the `@Inject` constructor — the no-arg constructor exists only to pass + validation. +- Constructor parameters become `private val` in the primary constructor. +- Return type `ProductDto` becomes `ProductDto?` where the service may return null. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/RETROFIT.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/RETROFIT.md new file mode 100644 index 0000000..9039126 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/RETROFIT.md @@ -0,0 +1,151 @@ +# Retrofit / OkHttp Conversion Guide + +## When This Applies + +Detected when imports match `retrofit2.*` or `okhttp3.*`. + +## Key Rules + +### 1. Interface declarations + +Retrofit service interfaces convert directly — Kotlin interfaces are structurally +identical to Java interfaces for this purpose. + +### 2. Call\ to suspend functions + +Replace `Call` return types with `suspend fun` returning `T` directly. This requires +the Retrofit coroutine adapter (built-in since Retrofit 2.6.0). The `Callback` +async pattern is eliminated entirely. + +### 3. Annotation preservation + +All Retrofit annotations transfer directly with no changes: +- HTTP method annotations: `@GET`, `@POST`, `@PUT`, `@DELETE`, `@PATCH`, `@HTTP` +- Header annotations: `@Headers`, `@Header`, `@HeaderMap` +- Parameter annotations: `@Path`, `@Query`, `@QueryMap`, `@Body`, `@Field`, + `@FieldMap`, `@Part`, `@PartMap` +- `@FormUrlEncoded`, `@Multipart`, `@Streaming` + +### 4. Response\ handling + +For endpoints where HTTP status codes matter, keep `Response` as the return type +with `suspend fun`. For simple cases where only the body is needed, return `T` directly +and let Retrofit throw on non-2xx responses. + +### 5. OkHttpClient.Builder + +Java builder chains convert directly. Use `.apply {}` or `.also {}` for grouping +related configuration: + +```kotlin +val client = OkHttpClient.Builder().apply { + connectTimeout(30, TimeUnit.SECONDS) + readTimeout(30, TimeUnit.SECONDS) + addInterceptor(loggingInterceptor) +}.build() +``` + +### 6. Interceptor SAM conversion + +Java `Interceptor` anonymous classes become Kotlin SAM lambdas: +`Interceptor { chain -> chain.proceed(chain.request()) }` + +### 7. Request/Response body handling + +`RequestBody.create(mediaType, content)` → `content.toRequestBody(mediaType)` when +using the `okhttp3-kotlin-extensions` artifact (or `okhttp-bom` with Kotlin extensions). + +--- + +## Example: Retrofit Interface with Coroutine Support + +### Java Input + +```java +package com.acme.api; + +import java.util.List; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import retrofit2.http.Body; +import retrofit2.http.DELETE; +import retrofit2.http.GET; +import retrofit2.http.Headers; +import retrofit2.http.PATCH; +import retrofit2.http.POST; +import retrofit2.http.Path; +import retrofit2.http.Query; + +/** + * Retrofit service interface for the Users API. + */ +public interface UserApi { + + @GET("users") + Call> getUsers(@Query("page") int page, @Query("limit") int limit); + + @GET("users/{id}") + Call getUserById(@Path("id") long id); + + @POST("users") + @Headers("Content-Type: application/json") + Call createUser(@Body CreateUserRequest request); + + @PATCH("users/{id}") + Call updateUser(@Path("id") long id, @Body UpdateUserRequest request); + + @DELETE("users/{id}") + Call deleteUser(@Path("id") long id); +} +``` + +### Kotlin Output + +```kotlin +package com.acme.api + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit service interface for the Users API. + */ +interface UserApi { + + @GET("users") + suspend fun getUsers(@Query("page") page: Int, @Query("limit") limit: Int): List + + @GET("users/{id}") + suspend fun getUserById(@Path("id") id: Long): UserDto + + @POST("users") + @Headers("Content-Type: application/json") + suspend fun createUser(@Body request: CreateUserRequest): UserDto + + @PATCH("users/{id}") + suspend fun updateUser(@Path("id") id: Long, @Body request: UpdateUserRequest): UserDto + + @DELETE("users/{id}") + suspend fun deleteUser(@Path("id") id: Long): Response +} +``` + +**Key points:** +- `Call` is removed — each method becomes a `suspend fun` returning `T` directly. + Retrofit 2.6.0+ supports this natively without an additional adapter. +- `Call` becomes `Response`. `Unit` is Kotlin's equivalent of `Void`. + `Response` is used here to allow checking the HTTP status code on delete. +- `Call` and `Callback` imports are removed since they are no longer referenced. +- All HTTP method and parameter annotations (`@GET`, `@POST`, `@Path`, `@Query`, + `@Body`, `@Headers`, etc.) are preserved exactly as-is. +- Java `int` → Kotlin `Int`, Java `long` → Kotlin `Long`. +- The `public` modifier on the interface is removed — Kotlin's default visibility + is public. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/RXJAVA.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/RXJAVA.md new file mode 100644 index 0000000..bb9e5da --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/RXJAVA.md @@ -0,0 +1,181 @@ +# RxJava to Coroutines/Flow Conversion Guide + +## When This Applies + +Detected when imports match `io.reactivex.*` or `rx.*`. This is a significant paradigm +shift — RxJava reactive types map to Kotlin coroutines and Flow. + +## Key Rules + +### 1. Dependency setup + +Add `kotlinx-coroutines-core` and `kotlinx-coroutines-rx3` (or `kotlinx-coroutines-rx2`) +as dependencies if performing a gradual migration. The bridge library provides extension +functions like `asFlow()` and `asObservable()` for interop at module boundaries. + +### 2. Type mapping + +| RxJava | Kotlin | +|---|---| +| `Observable` | `Flow` | +| `Flowable` | `Flow` (backpressure is built-in) | +| `Single` | `suspend fun`: T | +| `Maybe` | `suspend fun`: T? | +| `Completable` | `suspend fun` returning `Unit` | +| `Disposable` | `Job` (from coroutines) | +| `CompositeDisposable` | `CoroutineScope` (structured concurrency) | + +### 3. Operator mapping + +| RxJava | Kotlin Flow | +|---|---| +| `subscribeOn(Schedulers.io())` | `flowOn(Dispatchers.IO)` | +| `observeOn(AndroidSchedulers.mainThread())` | `flowOn(Dispatchers.Main)` or collect on Main | +| `flatMap` | `flatMapMerge` or `flatMapConcat` | +| `map` | `map` (same) | +| `filter` | `filter` (same) | +| `zip` | `combine` or `zip` | +| `merge` | `merge` | +| `concat` | `flatMapConcat` | +| `onErrorReturn` | `catch { emit(default) }` | +| `doOnNext` | `onEach` | +| `subscribe()` | `collect {}` in a coroutine scope | + +### 4. Error handling + +RxJava's `onError` callback maps to Flow's `catch` operator or a try-catch block +wrapping the `collect` call. In suspend functions (replacing `Single`/`Completable`), +use standard try-catch. + +### 5. Backpressure + +Flow has built-in backpressure via suspension. There is no need for a separate +`Flowable` type — all `Flow` instances support backpressure by default. + +### 6. Threading + +`flowOn` changes the upstream dispatcher (analogous to `subscribeOn`). Collection +always happens on the caller's dispatcher. To collect on a specific dispatcher, +launch the collecting coroutine in the desired scope. + +### 7. Lifecycle and cancellation + +RxJava's `Disposable` / `CompositeDisposable` pattern is replaced by structured +concurrency. Cancelling a `CoroutineScope` cancels all child coroutines and flow +collections automatically. + +--- + +## Example: Converting an Observable Chain to Flow + +### Java Input + +```java +package com.acme.data; + +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +/** + * Repository that streams user data from a remote source. + */ +public class UserRepository { + + private final UserApi api; + private final CompositeDisposable disposables = new CompositeDisposable(); + + public UserRepository(UserApi api) { + this.api = api; + } + + public Observable> getActiveUsers() { + return api.getAllUsers() + .subscribeOn(Schedulers.io()) + .map(users -> filterActive(users)) + .doOnNext(users -> logCount(users)) + .onErrorReturn(throwable -> Collections.emptyList()); + } + + public void observeUsers(UserCallback callback) { + disposables.add( + getActiveUsers() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + users -> callback.onUsers(users), + error -> callback.onError(error) + ) + ); + } + + public void clear() { + disposables.clear(); + } + + private List filterActive(List users) { + return users.stream().filter(User::isActive).collect(Collectors.toList()); + } + + private void logCount(List users) { + System.out.println("Active users: " + users.size()); + } +} +``` + +### Kotlin Output + +```kotlin +package com.acme.data + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +/** + * Repository that streams user data from a remote source. + */ +class UserRepository( + private val api: UserApi +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun getActiveUsers(): Flow> = + api.getAllUsers() + .map { users -> users.filter { it.isActive } } + .onEach { users -> println("Active users: ${users.size}") } + .catch { emit(emptyList()) } + .flowOn(Dispatchers.IO) + + fun observeUsers(callback: UserCallback) { + scope.launch { + getActiveUsers().collect { users -> + callback.onUsers(users) + } + } + } + + fun clear() { + scope.cancel() + } +} +``` + +**Key points:** +- `Observable>` becomes `Flow>`. +- `subscribeOn(Schedulers.io())` becomes `flowOn(Dispatchers.IO)` at the end of the + chain (it affects all upstream operators). +- `CompositeDisposable` is replaced by a `CoroutineScope` with `SupervisorJob`. + Calling `scope.cancel()` cancels all active collections. +- `doOnNext` becomes `onEach`. +- `onErrorReturn` becomes `catch { emit(emptyList()) }`. +- `observeOn(AndroidSchedulers.mainThread())` is unnecessary because `scope` already + uses `Dispatchers.Main`, and `collect` runs on the collector's dispatcher. +- Java streams (`filter` + `collect`) become Kotlin's `filter` directly on the list. diff --git a/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/SPRING.md b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/SPRING.md new file mode 100644 index 0000000..ae07434 --- /dev/null +++ b/.agents/skills/kotlin-tooling-java-to-kotlin/references/frameworks/SPRING.md @@ -0,0 +1,238 @@ +# Spring Framework Conversion Guide + +## When This Applies + +This guide applies when the Java source contains imports matching `org.springframework.*`. +This covers Spring Boot, Spring MVC, Spring Data, and Spring Security. + +## Key Rules + +### 1. SpringApplication.run — spread CLI args + +In Kotlin, `String[]` varargs must be spread with the `*` operator. + +- Java: `SpringApplication.run(App.class, args);` +- Kotlin: `SpringApplication.run(App::class.java, *args)` + +### 2. Constructor injection over @Autowired + +Kotlin's primary constructor makes constructor injection natural. When a class has a +single constructor, Spring auto-discovers it — remove `@Autowired`. + +### 3. Stereotype annotations + +`@Component`, `@Service`, `@RestController`, and `@Repository` target the class. +Preserve these annotations exactly. No annotation site target is needed. + +### 4. @Value annotation + +Use `@Value` on constructor parameters. Escape `$` in SpEL expressions to prevent +Kotlin string template interpretation: + +```kotlin +@Value("\${app.name}") val appName: String +``` + +### 5. @ConfigurationProperties + +Convert to a `data class` only if the properties are immutable. For mutable +configuration, use a regular class with `lateinit var`. + +### 6. Spring Data repositories + +Interface declarations convert directly. Replace `Optional` return types with +nullable `T?` in Kotlin for idiomatic usage. + +### 7. @RequestMapping / @GetMapping / @PostMapping etc. + +Preserve exactly. Where Java uses array initializer syntax for annotation parameters, +use `arrayOf()` in Kotlin. + +### 8. @Transactional + +Preserve exactly. The class must remain `open` because Spring creates proxies via +subclassing. Do not make `@Transactional` classes `final`. + +### 9. @Bean methods in @Configuration classes + +`@Bean` methods must be `open` so that Spring can override them in CGLIB proxies. +Alternatively, apply the `allopen` compiler plugin with a Spring preset, which makes +annotated classes and their members open automatically. + +--- + +## Examples + +### Example 1: Spring Boot Application Main Class + +**Java:** + +```java +package com.acme; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} +``` + +**Kotlin:** + +```kotlin +package com.acme + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +open class Application + +fun main(args: Array) { + runApplication(*args) +} +``` + +Key changes: +- `main` becomes a top-level function (no companion object needed). +- `runApplication` is a Spring Boot Kotlin extension that replaces + `SpringApplication.run(T::class.java, *args)`. +- The `*args` spread operator is required for the varargs parameter. + +--- + +### Example 2: REST Controller with Constructor Injection + +**Java:** + +```java +package com.acme.web; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + + @Autowired + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/{id}") + public UserDto getUser(@PathVariable Long id) { + return userService.findById(id); + } + + @GetMapping + public List getAllUsers() { + return userService.findAll(); + } +} +``` + +**Kotlin:** + +```kotlin +package com.acme.web + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/users") +class UserController( + private val userService: UserService +) { + + @GetMapping("/{id}") + fun getUser(@PathVariable id: Long): UserDto? { + return userService.findById(id) + } + + @GetMapping + fun getAllUsers(): List { + return userService.findAll() + } +} +``` + +Key changes: +- `@Autowired` is removed — Spring auto-discovers the single constructor. +- The `Autowired` import is removed because it is no longer referenced. +- Constructor parameter becomes a `private val` in the primary constructor. +- Return type `UserDto` becomes `UserDto?` where the service may return null. + +--- + +### Example 3: @ConfigurationProperties Class + +**Java:** + +```java +package com.acme.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.mail") +public class MailProperties { + + private String host; + private int port = 587; + private String username; + private String password; + + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } + + public int getPort() { return port; } + public void setPort(int port) { this.port = port; } + + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } +} +``` + +**Kotlin (mutable config with lateinit var):** + +```kotlin +package com.acme.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "app.mail") +open class MailProperties { + lateinit var host: String + var port: Int = 587 + lateinit var username: String + lateinit var password: String +} +``` + +Key changes: +- Getters and setters are replaced by Kotlin properties. +- `lateinit var` is used for required `String` properties that Spring populates + after construction. +- `port` keeps its default value and uses a regular `var` (`lateinit` does not + support primitive types). +- The class is `open` so that Spring can create a CGLIB proxy for it. diff --git a/.agents/skills/kotlin-tooling-native-build-performance/SKILL.md b/.agents/skills/kotlin-tooling-native-build-performance/SKILL.md new file mode 100644 index 0000000..fa0ae03 --- /dev/null +++ b/.agents/skills/kotlin-tooling-native-build-performance/SKILL.md @@ -0,0 +1,154 @@ +--- +name: kotlin-tooling-native-build-performance +description: > + Diagnoses and fixes slow Kotlin/Native compilation and linking in Kotlin + Multiplatform projects that target iOS. Use when the user reports slow iOS or + shared-framework builds, long linkDebug*/linkRelease* or XCFramework tasks, + cold CI builds that re-download the Kotlin/Native toolchain, KSP or other + generated code on the native path, transitiveExport usage, or asks for a + local-development versus CI build performance plan. +license: Apache-2.0 +metadata: + author: JetBrains + version: "1.0.0" + tested_models: "openai/gpt-5.5, openai/gpt-5.4-mini" + last_eval: "2026-07-06" +--- + +# Kotlin/Native Build Performance + +Turn "the iOS build is slow" into a measured diagnosis and a small set of safe +fixes. Two rules apply throughout: + +1. Never trade away required release behavior. A faster local loop must not + change what CI publishes. +2. Measure before and after with the same command and the same build state. + An unmeasured fix is a guess. + +## Step 0: Classify the Slow Scenario + +Establish four facts before editing anything: **where** (local or CI), +**what** (debug feedback loop or release/distribution artifact), **state** +(first build, clean, warm, or no-op), and **phase** (which tasks dominate the +log). Then match the dominant symptom: + +| Symptom in the build log | Likely cause | Read | +|---|---|---| +| `linkRelease*` or `*ReleaseXCFramework` tasks in a local development loop | Building distribution artifacts for development | [artifacts-and-targets](references/artifacts-and-targets.md) | +| Kotlin/Native compiler distribution downloaded on every CI run | `~/.konan` not preserved between runs | [caching-and-gradle](references/caching-and-gradle.md) | +| Long pause before the first task starts | Configuration phase, no configuration cache | [caching-and-gradle](references/caching-and-gradle.md) | +| All iOS targets build when only one simulator is needed | Broad task (`build`, `assemble`, `assemble*XCFramework`) or unused targets | [artifacts-and-targets](references/artifacts-and-targets.md) | +| `ksp*` tasks ahead of `compileKotlinIos*` | Generated-code work on the native path | [exports-and-generated-code](references/exports-and-generated-code.md) | +| Small source edit recompiles and relinks everything | Compiler caches disabled, or missing incrementality | [caching-and-gradle](references/caching-and-gradle.md), [experimental](references/experimental.md) | +| Machine overloaded while several `link*` tasks run at once | Parallel native linking | [caching-and-gradle](references/caching-and-gradle.md), worker-limit caveat | + +## Step 1: Audit and Measure + +1. Run the static audit from the project root: + + ```bash + scripts/audit-native-build.sh /path/to/project + ``` + + It is read-only and prints `file:line` findings (disabled caches, broad + local tasks, `transitiveExport`, broad KSP configuration, missing CI + `.konan` cache), each pointing at the reference file with the fix. + Findings are leads, not verdicts — confirm each against project policy. +2. Find the command the user actually waits for: a script, a CI step, or the + Gradle invocation inside an Xcode build phase. Optimize that command, not + a task you picked yourself. +3. Run it twice when practical. The first build downloads Kotlin/Native + components and fills caches; only the second and later runs are + representative. Attribute time per task before blaming the compiler: + + ```properties + kotlin.build.report.output=file # writes build/reports/kotlin-build/ + ``` + + Gradle's `--scan` or `--profile` work too. +4. If you cannot run the build (no macOS host, no Xcode), analyze logs, build + scans, or checked-in metrics instead — and state explicitly that the + conclusion is static. + +## Step 2: Fix in Safe Order + +Apply fixes one at a time, re-measuring as you go: + +1. **Restore healthy defaults** — remove cache/daemon workarounds, enable + Gradle build and configuration caches, keep `~/.konan` warm in CI, update + Kotlin: [references/caching-and-gradle.md](references/caching-and-gradle.md) +2. **Build only what the feedback loop needs** — one specific task per loop, + correct integration method, justified target matrix: + [references/artifacts-and-targets.md](references/artifacts-and-targets.md) +3. **Cut export and generated-code cost** — drop `transitiveExport`, narrow + `export(...)`, scope KSP work to the native compilations that need it: + [references/exports-and-generated-code.md](references/exports-and-generated-code.md) +4. **Experimental switches last, with the user's agreement**: + [references/experimental.md](references/experimental.md) + +## Worked Example + +A developer on an Apple Silicon Mac complains that "every shared-module +change costs 12 minutes". Their loop runs `./gradlew :shared:assembleXCFramework`. +A build scan of the second (warm) run shows: + +``` +:shared:linkReleaseFrameworkIosArm64 348s +:shared:linkReleaseFrameworkIosX64 341s +:shared:compileKotlinIosX64 96s +:shared:linkDebugFrameworkIosSimulatorArm64 41s +:shared:compileKotlinIosSimulatorArm64 38s +configuration phase 64s +``` + +Reasoning chain: + +- The loop is **local + debug + warm**, but ~690s goes to `linkRelease*` — + release linking is an order of magnitude slower than debug and only CI + needs it. Replace the local command with + `:shared:linkDebugFrameworkIosSimulatorArm64` (or the Xcode embed task if + Xcode drives the build). *(artifacts-and-targets)* +- All `iosX64` work serves Intel simulators; ask whether the team still + supports them before removing the target. *(artifacts-and-targets)* +- 64s of configuration on every run disappears behind + `org.gradle.configuration-cache=true` once trialed. *(caching-and-gradle)* +- Expected loop after the change: ~40s compile + ~40s link on warm builds — + confirm by re-running the new command twice and comparing. +- CI keeps `assembleXCFramework` untouched; note that explicitly in the + report. + +## Verify + +- [ ] Re-run the exact baseline command; compare warm build against warm + build, not warm against cold. +- [ ] Second run with the configuration cache reports it is being reused. +- [ ] The local development log no longer contains `linkRelease*`, + `*ReleaseXCFramework`, or removed generator tasks. +- [ ] CI still produces every required release artifact, unchanged. +- [ ] Tests pass and the app still runs from Xcode. +- [ ] `scripts/audit-native-build.sh` reports no findings you have not + consciously accepted and documented. + +## Report Your Changes + +Close with a short performance note: + +- The slow scenario (local/CI, debug/release, cold/warm) and the measured + evidence — or a statement that the analysis was static. +- Each change, and why it is safe for release behavior. +- The before/after commands the user can run to confirm the win. +- Remaining tradeoffs: experimental flags enabled, targets removed under a + policy assumption, worker limits, or generated-code work deferred. +- Links to the relevant official documentation below. + +## Official Documentation + +| Topic | Link | +|---|---| +| Improving Kotlin/Native compilation time | https://kotlinlang.org/docs/native-improving-compilation-time.html | +| Kotlin Gradle plugin compilation and caches | https://kotlinlang.org/docs/gradle-compilation-and-caches.html | +| iOS integration methods | https://kotlinlang.org/docs/multiplatform-ios-integration-overview.html | +| Direct integration with Xcode | https://kotlinlang.org/docs/multiplatform/multiplatform-direct-integration.html | +| Building final native binaries and XCFrameworks | https://kotlinlang.org/docs/multiplatform/multiplatform-build-native-binaries.html | +| Kotlin/Native binary options | https://kotlinlang.org/docs/native-binary-options.html | +| KSP with Kotlin Multiplatform | https://kotlinlang.org/docs/ksp-multiplatform.html | diff --git a/.agents/skills/kotlin-tooling-native-build-performance/evals/EVALUATION.md b/.agents/skills/kotlin-tooling-native-build-performance/evals/EVALUATION.md new file mode 100644 index 0000000..d75c72e --- /dev/null +++ b/.agents/skills/kotlin-tooling-native-build-performance/evals/EVALUATION.md @@ -0,0 +1,32 @@ +# Testing + +This skill is A/B evaluated with the JetBrains [`skills-ab-eval`](https://github.com/JetBrains/skills-ab-eval-cookbook) tool. +The suite lives at +[`kotlin-tooling-native-build-performance`](https://github.com/JetBrains/skills-ab-eval-cookbook/tree/main/kotlin-tooling-native-build-performance) +and contains two tasks: + +- **`native-build-performance-audit-task`** — a synthetic KMP iOS fixture + seeded with common Kotlin/Native build-performance mistakes. +- **`kotlinproject-native-build-performance-task`** — a KotlinProject template + copy with intentional cache, target, local-build, CI, and export regressions. + +Each task runs the agent with and without the skill and scores the result on a +weighted rubric (reward 0–1), requiring a `BUILD_PERFORMANCE_REPORT.md` that +preserves production release behavior. + +## Latest results (2026-07-06) + +Run via `skills-ab-eval` on the `codex` agent, `openai/gpt-5.5` at low reasoning +effort, n = 6 pairs per task: + +| Task | Without skill | With skill | Δ | Significance | +|---|---:|---:|---:|---| +| Synthetic native build audit | 0.74 ± 0.05 | 0.99 ± 0.02 | +0.25 | p = 0.031 | +| KotlinProject native build audit | 0.59 ± 0.05 | 0.90 ± 0.02 | +0.31 | p = 0.031 | + +The with-skill arms are near-deterministic (σ ≤ 0.02): the diagnostic procedure +lives in the skill, not in the model's reasoning budget. Additional +`openai/gpt-5.4-mini` runs (high and low reasoning) are recorded per task. + +See each task's `EVALUATION.md` in the cookbook for full per-trial reward +breakdowns and token/cost metrics. diff --git a/.agents/skills/kotlin-tooling-native-build-performance/references/artifacts-and-targets.md b/.agents/skills/kotlin-tooling-native-build-performance/references/artifacts-and-targets.md new file mode 100644 index 0000000..535c424 --- /dev/null +++ b/.agents/skills/kotlin-tooling-native-build-performance/references/artifacts-and-targets.md @@ -0,0 +1,44 @@ +# Build the Right Artifact for the Feedback Loop + +A release binary takes roughly an order of magnitude longer to build than a +debug binary, and umbrella tasks such as `build` and `assemble` compile the +same code several times. Map each feedback loop to one specific task. + +## Task table + +| Feedback loop | Correct task | +|---|---| +| Xcode builds and runs the app (direct integration) | `:shared:embedAndSignAppleFrameworkForXcode` | +| Gradle-only check of the Apple Silicon simulator framework | `:shared:linkDebugFrameworkIosSimulatorArm64` | +| CocoaPods integration | `:shared:linkPodDebugFrameworkIosSimulatorArm64` | +| Distribution or App Store validation | `assembleReleaseXCFramework` — in CI, not in the local loop | +| Debug XCFramework genuinely required | `assembleDebugXCFramework` | + +Per-target link tasks follow the pattern +`linkFramework`; find the exact names with +`./gradlew :shared:tasks` or in the build log. + +## Rules + +- `embedAndSignAppleFrameworkForXcode` builds only the slice Xcode asked for + and must run from an Xcode build phase, not standalone. Direct integration + uses the documented run-script phase; keep its + `OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED` guard so the IDE does not trigger a + second Gradle invocation. +- Do not mix integration methods: a project either uses direct integration or + the CocoaPods integration, and the local task must match the one in use. +- Do not replace CI release artifacts with debug artifacts. If CI release + builds are slow, fix caching, the target matrix, and exports instead. + +## Target matrix + +`*XCFramework` tasks build every declared target. Remove a target only when +project policy confirms it is unused — the common case is dropping +`iosX64()` when the team no longer supports Intel-based simulators. State the +policy assumption in your report; if policy is unclear, ask instead of +deleting. + +Docs: +https://kotlinlang.org/docs/multiplatform-ios-integration-overview.html, +https://kotlinlang.org/docs/multiplatform/multiplatform-direct-integration.html, +https://kotlinlang.org/docs/multiplatform/multiplatform-build-native-binaries.html diff --git a/.agents/skills/kotlin-tooling-native-build-performance/references/caching-and-gradle.md b/.agents/skills/kotlin-tooling-native-build-performance/references/caching-and-gradle.md new file mode 100644 index 0000000..345418c --- /dev/null +++ b/.agents/skills/kotlin-tooling-native-build-performance/references/caching-and-gradle.md @@ -0,0 +1,68 @@ +# Caching and Gradle Configuration + +Safe for every scenario; apply these before anything else. + +## Update Kotlin + +The latest Kotlin version is the first official recommendation for +Kotlin/Native compilation time — each release improves compiler performance. +Check `gradle/libs.versions.toml` or the plugin block and propose an upgrade +if the project is behind. Read the compatibility guide for the target release +before upgrading; for example, use the +[Kotlin 2.4 compatibility guide](https://kotlinlang.org/docs/compatibility-guide-24.html) +when moving to Kotlin 2.4.x. Each target release has a corresponding +compatibility guide. See the +[Kotlin 2.3.20 release notes](https://kotlinlang.org/docs/whatsnew2320.html#new-dsl-for-disabling-compilation-cache) +for the related cache change. + +## Remove stale workarounds + +Projects accumulate workarounds for long-fixed compiler issues. Upgrade Kotlin +first, then inspect: + +- `kotlin.native.disableCompilerDaemon=true` +- `org.gradle.daemon=false` + +Each disables a performance feature. Remove stale workarounds and check +whether the build completes successfully. + +## Enable Gradle caching + +```properties +# gradle.properties +org.gradle.caching=true +org.gradle.configuration-cache=true +``` + +- Trial the configuration cache with the user's real task before committing + it. If Gradle reports configuration-cache problems, fix the blockers listed + in the HTML report instead of abandoning the cache. +- The configuration cache implicitly enables parallel task execution, which + can run several `link*` tasks at once and overload the machine (KT-70915). + If that happens, bound it with `org.gradle.workers.max` in + `gradle.properties` or `--max-workers` on the command line — do not turn + the cache off for this reason alone. +- Delete `org.gradle.configureondemand=true`. Kotlin Multiplatform does not + support Configuration on Demand, and it is not the same feature as the + configuration cache. +- For CI, a remote Gradle build cache extends `org.gradle.caching` across + machines. + +## Keep `~/.konan` warm in CI + +Kotlin/Native stores its compiler distribution and caches in `$HOME/.konan`. +Ephemeral CI machines and containers that lose it pay the cold-start cost on +every build. On GitHub Actions: + +```yaml +- uses: actions/cache@v4 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('**/libs.versions.toml') }} +``` + +Use the `konan.data.dir` Gradle property only when the project intentionally +relocates that directory (for example, to a cacheable path on a CI runner). + +Docs: https://kotlinlang.org/docs/native-improving-compilation-time.html and +https://kotlinlang.org/docs/gradle-compilation-and-caches.html diff --git a/.agents/skills/kotlin-tooling-native-build-performance/references/experimental.md b/.agents/skills/kotlin-tooling-native-build-performance/references/experimental.md new file mode 100644 index 0000000..59d55ec --- /dev/null +++ b/.agents/skills/kotlin-tooling-native-build-performance/references/experimental.md @@ -0,0 +1,32 @@ +# Experimental Switches + +Offer these last, label them experimental in the report, and keep them out of +the default recommendation set. Get the user's agreement before enabling any +of them. + +## Incremental compilation of klib artifacts + +```properties +# gradle.properties +kotlin.incremental.native=true +``` + +Recompiles only the changed part of a klib into the final binary, which helps +warm rebuilds after small edits. If it causes broken or inconsistent builds, +revert it and file a YouTrack issue with a minimized reproducer. + +## smallBinary + +The `smallBinary` binary option sets `-Oz` as the default LLVM optimization +level to shrink release binaries and their link time. It can cost runtime +performance, so verify hot paths before keeping it. It applies to release +binaries — it is not a fix for slow debug loops. + +## LLVM backend customization + +Customizing the LLVM backend is a last resort when nothing else helps, and is +out of scope for a routine performance pass — point the user at the official +documentation instead of improvising compiler flags. + +Docs: https://kotlinlang.org/docs/native-binary-options.html and +https://kotlinlang.org/docs/native-improving-compilation-time.html diff --git a/.agents/skills/kotlin-tooling-native-build-performance/references/exports-and-generated-code.md b/.agents/skills/kotlin-tooling-native-build-performance/references/exports-and-generated-code.md new file mode 100644 index 0000000..a4883ac --- /dev/null +++ b/.agents/skills/kotlin-tooling-native-build-performance/references/exports-and-generated-code.md @@ -0,0 +1,50 @@ +# Framework Exports and Generated Code + +## Framework exports + +Every exported module grows the API surface the compiler and linker must +keep. + +- Remove `transitiveExport = true`. It exports the entire transitive closure + and disables dead code elimination in many cases — it is almost never what + the project actually needs. +- Keep an explicit `export(...)` only for modules whose API Swift or + Objective-C code calls directly. + +```kotlin +// Before: exports everything analytics depends on, defeats DCE +binaries.framework { + export(project(":analytics")) + transitiveExport = true +} + +// After: exports exactly the Swift-facing API +binaries.framework { + export(project(":analytics")) +} +``` + +If Swift code stops compiling after narrowing exports, add back only the +specific modules it references — that is the export list the project really +needs. + +## Generated code + +If `ksp*` tasks dominate the measured time, report the bottleneck as +generated-code work — do not present a Kotlin/Native tweak as the fix. + +- Scope KSP to the targets or source sets that need generated code instead of + a broad `ksp(...)` dependency: + + ```kotlin + dependencies { + add("kspCommonMainMetadata", libs.myprocessor) + add("kspIosSimulatorArm64", libs.myprocessor) + } + ``` + +- Confirm the generated sources are consumed by the corresponding native + compilation before adding another target-specific KSP configuration. + +Docs: https://kotlinlang.org/docs/ksp-multiplatform.html and +https://kotlinlang.org/docs/native-improving-compilation-time.html diff --git a/.agents/skills/kotlin-tooling-native-build-performance/scripts/audit-native-build.sh b/.agents/skills/kotlin-tooling-native-build-performance/scripts/audit-native-build.sh new file mode 100644 index 0000000..2c8b414 --- /dev/null +++ b/.agents/skills/kotlin-tooling-native-build-performance/scripts/audit-native-build.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Static audit for Kotlin/Native build performance in a KMP project. +# +# Read-only: scans Gradle properties, build scripts, shell scripts, and CI +# workflows for configurations known to slow Kotlin/Native builds, and prints +# findings as `[SEVERITY] file:line message -> reference`. +# +# Usage: audit-native-build.sh [project-root] (default: current directory) +# +# Exit code: 0 always (findings are advice, not errors), unless the root is +# not a Gradle project at all. + +set -uo pipefail + +ROOT="${1:-.}" + +if [ ! -e "$ROOT/settings.gradle.kts" ] && [ ! -e "$ROOT/settings.gradle" ] \ + && [ ! -e "$ROOT/build.gradle.kts" ] && [ ! -e "$ROOT/build.gradle" ]; then + echo "error: $ROOT does not look like a Gradle project root" >&2 + exit 1 +fi + +FINDINGS=0 +EXCLUDES=(--exclude-dir=.git --exclude-dir=build --exclude-dir=.gradle --exclude-dir=.kotlin) + +# scan ... +scan() { + local severity="$1" message="$2" reference="$3" pattern="$4" + shift 4 + local includes=() + for glob in "$@"; do includes+=(--include="$glob"); done + local hits + hits=$(grep -RInE "${EXCLUDES[@]}" "${includes[@]}" -e "$pattern" "$ROOT" 2>/dev/null \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*(#|//)' || true) + [ -z "$hits" ] && return 0 + while IFS= read -r hit; do + FINDINGS=$((FINDINGS + 1)) + printf '[%s] %s\n %s\n -> %s\n' \ + "$severity" "${hit%%:*}:$(echo "$hit" | cut -d: -f2)" "$message" "$reference" + done <<< "$hits" +} + +# require_property +# Reports when no gradle.properties sets key=value (commented lines ignored). +require_property() { + local message="$1" reference="$2" key="$3" value="$4" + if ! grep -RInE "${EXCLUDES[@]}" --include='gradle.properties' \ + -e "^[[:space:]]*${key}[[:space:]]*=[[:space:]]*${value}[[:space:]]*$" \ + "$ROOT" >/dev/null 2>&1; then + FINDINGS=$((FINDINGS + 1)) + printf '[%s] %s\n %s\n -> %s\n' \ + "MEDIUM" "gradle.properties" "$message" "$reference" + fi +} + +echo "== Kotlin/Native build performance audit: $ROOT ==" +echo + +## 1. Disabled performance defaults (highest impact, safest to fix) + +scan HIGH \ + "Kotlin/Native compiler daemon disabled" \ + "references/caching-and-gradle.md: remove stale workarounds" \ + '^[[:space:]]*kotlin\.native\.disableCompilerDaemon[[:space:]]*=[[:space:]]*true' \ + 'gradle.properties' + +scan HIGH \ + "Gradle daemon disabled" \ + "references/caching-and-gradle.md: remove stale workarounds" \ + '^[[:space:]]*org\.gradle\.daemon[[:space:]]*=[[:space:]]*false' \ + 'gradle.properties' + +scan MEDIUM \ + "Configuration on Demand is unsupported by KMP and is not the configuration cache" \ + "references/caching-and-gradle.md: enable Gradle caching" \ + '^[[:space:]]*org\.gradle\.configureondemand[[:space:]]*=[[:space:]]*true' \ + 'gradle.properties' + +scan MEDIUM \ + "Gradle build cache explicitly disabled" \ + "references/caching-and-gradle.md: enable Gradle caching" \ + '^[[:space:]]*org\.gradle\.caching[[:space:]]*=[[:space:]]*false' \ + 'gradle.properties' + +require_property \ + "org.gradle.caching=true is not set" \ + "references/caching-and-gradle.md: enable Gradle caching" \ + 'org\.gradle\.caching' 'true' + +require_property \ + "org.gradle.configuration-cache=true is not set (trial it with the real task first)" \ + "references/caching-and-gradle.md: enable Gradle caching" \ + 'org\.gradle\.configuration-cache' 'true' + +## 2. Framework exports + +scan HIGH \ + "transitiveExport = true disables dead code elimination in many cases" \ + "references/exports-and-generated-code.md: framework exports" \ + 'transitiveExport[[:space:]]*=[[:space:]]*true' \ + '*.gradle.kts' '*.gradle' + +## 3. Generated code on the native path + +scan INFO \ + "broad ksp(...) dependency; prefer per-target add(\"ksp\", ...) in KMP" \ + "references/exports-and-generated-code.md: generated code" \ + '^[[:space:]]*ksp\(' \ + '*.gradle.kts' '*.gradle' + +## 4. Targets and local build scope + +scan INFO \ + "iosX64 target declared; confirm Intel-based simulators are still supported" \ + "references/artifacts-and-targets.md: target matrix" \ + 'iosX64[[:space:]]*\(' \ + '*.gradle.kts' '*.gradle' + +scan MEDIUM \ + "broad or release Gradle task in a shell script; if this is the local loop, narrow it" \ + "references/artifacts-and-targets.md: task table" \ + 'gradlew?[^#]*([[:space:]](clean|build|assemble)([[:space:]]|$)|XCFramework|linkRelease)' \ + '*.sh' + +## 5. CI cold starts + +WORKFLOW_HITS=$(grep -RIlE "${EXCLUDES[@]}" --include='*.yml' --include='*.yaml' \ + -e 'gradlew|gradle/actions|setup-gradle' "$ROOT/.github" 2>/dev/null || true) +for wf in $WORKFLOW_HITS; do + if ! grep -qE '\.konan' "$wf"; then + FINDINGS=$((FINDINGS + 1)) + printf '[%s] %s\n %s\n -> %s\n' \ + "MEDIUM" "$wf" \ + "workflow runs Gradle but does not cache ~/.konan (cold Kotlin/Native toolchain every run)" \ + "references/caching-and-gradle.md: keep .konan warm in CI" + fi +done + +## 6. Informational + +scan INFO \ + "konan.data.dir relocates the Kotlin/Native cache; confirm the new location is preserved" \ + "references/caching-and-gradle.md: keep .konan warm in CI" \ + '^[[:space:]]*konan\.data\.dir[[:space:]]*=' \ + 'gradle.properties' + +scan INFO \ + "experimental kotlin.incremental.native is enabled; keep it labeled experimental in reports" \ + "references/experimental.md" \ + '^[[:space:]]*kotlin\.incremental\.native[[:space:]]*=[[:space:]]*true' \ + 'gradle.properties' + +echo +if [ "$FINDINGS" -eq 0 ]; then + echo "No static findings. Measure before concluding the build is healthy:" + echo "run the user's real command twice and check per-task time with" + echo "kotlin.build.report.output=file or --scan." +else + echo "$FINDINGS finding(s). Confirm each against the project's policy before fixing;" + echo "measure before and after with the same command (see SKILL.md, Step 1)." +fi +exit 0 diff --git a/.gitignore b/.gitignore index 3820a95..c6ec466 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +# Widget Preview related +.widget_preview/ +android/.kotlin/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..063a8d8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# Flutter 项目规范 + +> 严格遵循本文档定义的架构模式、状态管理标准和代码风格。未经授权不得引入新的第三方包或架构模式。优先组合而非继承,业务逻辑不进入 UI 组件。 + +--- + +## 1. 技术栈 + +- **语言:** Dart(SDK >=3.0.0,严格空安全) +- **框架:** Flutter(最新稳定通道) +- **状态管理:** `flutter_riverpod`(v2.x,注解 + 代码生成) +- **路由:** `go_router` +- **网络请求:** `dio`(配合 `json_annotation` + `freezed` 进行 JSON 序列化) +- **数据模型:** `freezed` + `build_runner` +- **本地存储:** `shared_preferences`(非敏感偏好)+ `flutter_secure_storage`(令牌) +- **依赖注入:** Riverpod Providers(不使用 GetIt) +- **国际化:** `flutter_localizations`(l10n / ARB 格式) + +--- + +## 2. 目录结构 + +采用 **特性优先架构(Feature-First)**,特性内部结合 **整洁架构(Clean Architecture)** 分层: + +```text +lib/ +├── main.dart # 应用程序入口(ProviderScope + runApp) +├── app/ # 全局应用配置 +│ ├── app.dart # CupertinoApp.router 入口 +│ ├── router/ # 路由定义(GoRouter) +│ ├── theme/ # 主题、颜色、排版 +│ └── constants/ # 全局常量、API 端点、ICE 配置 +├── core/ # 跨特性共享代码 +│ ├── network/ # dio 客户端、错误处理 +│ ├── proto/ # Protobuf 生成代码(控制指令协议) +│ ├── storage/ # 本地存储辅助工具 +│ ├── utils/ # 辅助函数、扩展方法 +│ └── widgets/ # 共享 UI 组件(触摸层) +├── features/ # 特性模块 +│ ├── auth/ # 登录 / 令牌 / 绑定列表 +│ │ ├── data/ # Repository 实现(dio) +│ │ ├── domain/ # 仓库接口、状态模型(freezed) +│ │ └── presentation/ # 控制器、登录对话框 +│ └── connection/ # 信令 / WebRTC / 控制面板 +│ ├── data/ # 信令客户端、WebRTC、编排器、解码器、录制器 +│ ├── domain/ # 信令消息、会话状态(freezed) +│ └── presentation/ # 控制器、设置页、控制页、鉴权对话框 +└── l10n/ # 国际化(.arb 文件) +``` + +--- + +## 3. 分层职责 + +### 表示层(`presentation/`) + +UI 组件 + Riverpod Notifier / AsyncNotifier 控制器。 + +- 只能通过 `ref.watch` 或 `ref.listen` 消费状态。 +- 禁止在 `onPressed` 或 `build()` 中直接调用 API 或编写业务逻辑。 +- 一次性提示通过状态中的 `alert` 字段传递,UI 展示后调用 `consumeAlert()` 消费。 + +### 领域层(`domain/`) + +纯 Dart 实体(`freezed`)和仓库接口定义。 + +- 零 Flutter/UI 依赖(协议模型除外)。 +- 状态类命名避免与 Flutter SDK 冲突(如用 `ConnectionSessionState` 而非 `ConnectionState`)。 + +### 数据层(`data/`) + +实现仓库接口,通过 dio 处理 API 请求,将 JSON DTO 映射为领域实体。 + +--- + +## 4. 编码规范 + +### 状态管理(Riverpod) + +- 使用 `@riverpod` 注解 + `build_runner` 代码生成。 +- 会话级控制器(如登录、连接)使用 `@Riverpod(keepAlive: true)`,避免页面切换时销毁。 +- 异步操作优先使用 `AsyncNotifierProvider`,配合 `AsyncValue`(Loading / Data / Error)。 +- 业务回调(信令/WebRTC 事件)统一在控制器内映射到状态,不在 UI 层直接持有控制器实例。 + +### 数据建模(freezed) + +所有数据类/实体必须用 `freezed` 实现不可变,并配置 JSON 序列化: + +```dart +@freezed +class SignalMessage with _$SignalMessage { + const SignalMessage._(); + + const factory SignalMessage({ + String? type, + String? payload, + }) = _SignalMessage; + + factory SignalMessage.fromJson(Map json) => + _$SignalMessageFromJson(json); + + @override + String toString() => jsonEncode(toJson()); +} +``` + +### UI 组件 + +- 尽可能使用 `const` 构造函数,避免不必要的重建。 +- 复杂子树提取为独立私有/公有无状态组件,不要写冗长的内联辅助方法。 +- 响应式适配使用 `LayoutBuilder` / `MediaQuery` 或项目统一的屏幕适配工具。 + +--- + +## 5. 命名约定 + +| 类别 | 规范 | 示例 | +|---|---|---| +| 文件/文件夹 | `snake_case.dart` | `connection_controller.dart` | +| 类/枚举 | `PascalCase` | `ConnectionSessionState` | +| 变量/方法 | `camelCase` | `sendResolutionChange()` | +| Provider | 以 `Provider` 结尾 | `authRepositoryProvider` | +| 私有成员 | `_` 前缀 | `_handleSignalMessage()` | + +--- + +## 6. 代码生成 + +修改或新增带代码生成的模型/控制器后,运行: + +```bash +# 一次性生成(freezed / json_serializable / riverpod_generator) +dart run build_runner build --delete-conflicting-outputs + +# 监听模式 +dart run build_runner watch --delete-conflicting-outputs + +# 生成国际化(l10n.yaml 已配置) +flutter gen-l10n +``` + +--- + +## 7. 完成标准 + +- 新文件遵循特性优先结构(app / core / features / l10n)。 +- 代码为空安全、完全类型化,并进行 `const` 优化。 +- 对应创建数据层、领域层和表示层组件。 +- 提交前 `flutter analyze` 无 issue;`flutter test` 通过。 +- 协议(`.proto` / 信令 JSON)变更需同步 Android / iOS / Web 各端。 diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..bf8d421 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..3a7bd94 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,5 @@ +arb-dir: lib/l10n +template-arb-file: intl_zh.arb +output-localization-file: app_localizations.dart +output-class: AppLocalizations +nullable-getter: false diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..ba11fd0 --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,34 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../l10n/app_localizations.dart'; +import 'theme/app_theme.dart'; + +/// 应用根组件。 +/// +/// 使用 CupertinoApp.router 接入 GoRouter,并在 [ProviderScope] 内运行。 +class App extends StatelessWidget { + const App({super.key, required this.router}); + + final GoRouter router; + + @override + Widget build(BuildContext context) { + return CupertinoApp.router( + title: '桐桐家庭关怀', + theme: AppTheme.cupertinoTheme, + routerConfig: router, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + debugShowCheckedModeBanner: false, + ); + } +} + +/// 便于在 main 中统一构建 ProviderScope + App。 +Widget buildApp(GoRouter router) { + return ProviderScope( + child: App(router: router), + ); +} diff --git a/lib/app/constants/app_constants.dart b/lib/app/constants/app_constants.dart new file mode 100644 index 0000000..b6020d1 --- /dev/null +++ b/lib/app/constants/app_constants.dart @@ -0,0 +1,21 @@ +/// 全局常量与端点配置。 +class AppConstants { + const AppConstants._(); + + /// 生产环境基础地址。 + static const String kBaseUrl = 'https://api.ttstd.com'; + + /// 请求超时(毫秒)。 + static const int kConnectTimeoutMs = 15000; + + /// 响应超时(毫秒)。 + static const int kReceiveTimeoutMs = 15000; + + /// 安全存储键。 + static const String kTokenKey = 'auth_token'; + static const String kRefreshTokenKey = 'refresh_token'; + static const String kDeviceIdKey = 'device_id'; + + /// 应用名称。 + static const String kAppName = '桐桐家庭关怀'; +} diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart new file mode 100644 index 0000000..6a3a78b --- /dev/null +++ b/lib/app/router/app_router.dart @@ -0,0 +1,63 @@ +import 'package:go_router/go_router.dart'; + +import '../../features/auth/presentation/login_page.dart'; +import '../../features/auth/presentation/register_page.dart'; +import '../../features/auth/presentation/splash_page.dart'; +import '../../features/home/presentation/home_page.dart'; +import '../../features/home/presentation/home_tab.dart'; +import '../../features/messages/presentation/messages_tab.dart'; +import '../../features/profile/presentation/profile_tab.dart'; + +/// 应用路由定义(GoRouter)。 +/// +/// - /splash 启动页,依据登录态决定首跳 +/// - /login /register 认证流程 +/// - /home Shell 路由,承载底部 Tab(首页 / 消息 / 我的) +GoRouter buildAppRouter() { + return GoRouter( + initialLocation: '/splash', + routes: [ + GoRoute( + path: '/splash', + builder: (context, state) => const SplashPage(), + ), + GoRoute( + path: '/login', + builder: (context, state) => const LoginPage(), + ), + GoRoute( + path: '/register', + builder: (context, state) => const RegisterPage(), + ), + StatefulShellRoute.indexedStack( + builder: (context, state, shell) => HomePage(child: shell), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => const HomeTab(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/messages', + builder: (context, state) => const MessagesTab(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/profile', + builder: (context, state) => const ProfileTab(), + ), + ], + ), + ], + ), + ], + ); +} diff --git a/lib/app/theme/app_theme.dart b/lib/app/theme/app_theme.dart new file mode 100644 index 0000000..ed19eb8 --- /dev/null +++ b/lib/app/theme/app_theme.dart @@ -0,0 +1,20 @@ +import 'package:flutter/cupertino.dart'; + +/// 应用统一主题。 +/// +/// 采用 Cupertino 视觉风格,集中定义主色调与背景色,避免散落的硬编码颜色。 +class AppTheme { + const AppTheme._(); + + static const Color primary = CupertinoColors.activeBlue; + static const Color background = CupertinoColors.systemBackground; + static const Color groupedBackground = + CupertinoColors.systemGroupedBackground; + + /// 全局 Cupertino 主题配置。 + static CupertinoThemeData get cupertinoTheme => const CupertinoThemeData( + primaryColor: primary, + barBackgroundColor: background, + scaffoldBackgroundColor: background, + ); +} diff --git a/lib/core/network/api_exception.dart b/lib/core/network/api_exception.dart new file mode 100644 index 0000000..5ff6469 --- /dev/null +++ b/lib/core/network/api_exception.dart @@ -0,0 +1,15 @@ +/// 统一网络异常类型。 +/// +/// 业务错误(如验证码错误、账号冲突)通过 [code] / [message] 暴露给上层。 +class ApiException implements Exception { + const ApiException({required this.code, required this.message}); + + /// 业务错误码。 + final int code; + + /// 用户可读的错误描述。 + final String message; + + @override + String toString() => 'ApiException(code: $code, message: $message)'; +} diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..9252b4f --- /dev/null +++ b/lib/core/network/dio_client.dart @@ -0,0 +1,80 @@ +import 'package:dio/dio.dart'; + +import '../../app/constants/app_constants.dart'; +import 'api_exception.dart'; +import '../storage/token_storage.dart'; + +/// 统一 Dio 客户端。 +/// +/// 负责:基础地址、超时、认证头注入、统一响应解析与异常转换。 +/// 业务层(Repository)不应自行创建 Dio 实例。 +class DioClient { + DioClient._(); + + static Dio? _instance; + + /// 全局共享的 Dio 单例。 + static Dio get instance { + _instance ??= _create(); + return _instance!; + } + + static Dio _create() { + final dio = Dio( + BaseOptions( + baseUrl: AppConstants.kBaseUrl, + connectTimeout: + Duration(milliseconds: AppConstants.kConnectTimeoutMs), + receiveTimeout: + Duration(milliseconds: AppConstants.kReceiveTimeoutMs), + headers: {'Content-Type': 'application/json'}, + ), + ); + dio.interceptors.add(_AuthInterceptor()); + return dio; + } + + /// 统一解析响应体,提取 data 字段。 + /// + /// 约定后台返回结构:{ code, message, data },code == 0 表示成功。 + static dynamic parse(Response response) { + final body = response.data as Map?; + if (body == null) { + throw const ApiException(code: -1, message: '响应为空'); + } + final code = body['code'] as int? ?? -1; + final message = body['message'] as String? ?? '未知错误'; + if (code != 0) { + throw ApiException(code: code, message: message); + } + return body['data']; + } + + /// 统一 POST 请求封装。 + static Future post( + String path, { + Map? data, + }) async { + try { + final response = await instance.post(path, data: data); + return parse(response); + } on DioException catch (e) { + throw ApiException( + code: e.response?.statusCode ?? -1, + message: e.message ?? '网络请求失败', + ); + } + } +} + +/// 请求拦截器:自动为已登录会话注入 Authorization 头。 +class _AuthInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final token = TokenStorage.accessToken; + if (token != null && token.isNotEmpty) { + options.headers['Authorization'] = 'Bearer $token'; + } + super.onRequest(options, handler); + } +} diff --git a/lib/core/storage/token_storage.dart b/lib/core/storage/token_storage.dart new file mode 100644 index 0000000..08c573d --- /dev/null +++ b/lib/core/storage/token_storage.dart @@ -0,0 +1,121 @@ +import 'package:mmkv/mmkv.dart'; + +import '../../app/constants/app_constants.dart'; + +/// 令牌与登录态的本地存储封装。 +/// +/// 令牌属于敏感数据,统一通过本封装读写,UI 与仓库不直接接触底层存储。 +/// 当前基于已引入的 MMKV 实现;若后续切换为 flutter_secure_storage, +/// 仅需调整本文件内部实现,调用方无感知。 +/// +/// 健壮性:当 MMKV 未初始化(如单元测试宿主环境)或不可用(异常)时, +/// 自动降级为内存存储,保证存储访问不崩溃。 +class TokenStorage { + const TokenStorage._(); + + static bool _mmkvReady = false; + static _KVStore? _backend; + + /// 内存回退后端实例(始终可用)。 + static final _MemoryStore _memoryStore = _MemoryStore(); + + /// 在应用启动早期调用一次,确保 MMKV 根目录已就绪。 + /// + /// 内部已做超时与异常保护:MMKV 初始化若在真机上偶发阻塞或失败, + /// 会降级为内存存储并立即返回,避免阻塞 [main] 导致应用卡在首屏。 + static Future initialize() async { + if (_mmkvReady) return; + try { + await MMKV.initialize() + .timeout(const Duration(seconds: 5), onTimeout: () => throw StateError('mmkv init timeout')); + MMKV.defaultMMKV(); + _mmkvReady = true; + } catch (_) { + // 初始化失败或超时:降级为内存存储,不阻断应用启动。 + _mmkvReady = false; + } + } + + /// 当前可用的键值存储后端。 + static _KVStore get _store { + if (_mmkvReady) { + try { + MMKV.defaultMMKV(); + _backend ??= _MmkvStore(); + return _backend!; + } catch (_) { + // 访问异常时回退内存,避免抛出。 + } + } + return _memoryStore; + } + + /// 是否已登录(存在有效访问令牌)。 + static bool get isLoggedIn => + _store.getString(AppConstants.kTokenKey)?.isNotEmpty ?? false; + + /// 当前访问令牌,未登录时返回 null。 + static String? get accessToken => _store.getString(AppConstants.kTokenKey); + + /// 刷新令牌。 + static String? get refreshToken => + _store.getString(AppConstants.kRefreshTokenKey); + + /// 设备唯一标识,首次访问时生成并持久化。 + static String get deviceId { + final existing = _store.getString(AppConstants.kDeviceIdKey); + if (existing != null && existing.isNotEmpty) return existing; + final generated = DateTime.now().microsecondsSinceEpoch.toString(); + _store.setString(AppConstants.kDeviceIdKey, generated); + return generated; + } + + /// 保存登录结果中的令牌。 + static void saveTokens({required String token, String? refreshToken}) { + _store.setString(AppConstants.kTokenKey, token); + if (refreshToken != null) { + _store.setString(AppConstants.kRefreshTokenKey, refreshToken); + } + } + + /// 清除全部登录态(登出 / 令牌失效时使用)。 + static void clear() { + _store.removeKey(AppConstants.kTokenKey); + _store.removeKey(AppConstants.kRefreshTokenKey); + } +} + +/// 键值存储后端抽象。 +abstract class _KVStore { + String? getString(String key); + void setString(String key, String value); + void removeKey(String key); +} + +/// 内存后端(测试 / MMKV 不可用时的回退)。 +class _MemoryStore implements _KVStore { + final Map _data = {}; + + @override + String? getString(String key) => _data[key]; + + @override + void setString(String key, String value) => _data[key] = value; + + @override + void removeKey(String key) => _data.remove(key); +} + +/// MMKV 后端。 +class _MmkvStore implements _KVStore { + MMKV get _mmkv => MMKV.defaultMMKV(); + + @override + String? getString(String key) => _mmkv.decodeString(key); + + @override + void setString(String key, String value) => _mmkv.encodeString(key, value); + + @override + void removeKey(String key) => _mmkv.removeValue(key); +} diff --git a/lib/core/utils/device_info_util.dart b/lib/core/utils/device_info_util.dart new file mode 100644 index 0000000..f727a15 --- /dev/null +++ b/lib/core/utils/device_info_util.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; + +import '../storage/token_storage.dart'; + +/// 设备信息辅助工具。 +/// +/// 仅提供静态读取能力,结果在需要时即时获取,不在 UI 层缓存。 +class DeviceInfoUtil { + const DeviceInfoUtil._(); + + static final DeviceInfoPlugin _plugin = DeviceInfoPlugin(); + + /// 读取设备型号,失败时回退为 unknown。 + static Future getDeviceModel() async { + try { + if (Platform.isAndroid) { + final info = await _plugin.androidInfo; + return '${info.brand} ${info.model}'; + } else if (Platform.isIOS) { + final info = await _plugin.iosInfo; + return info.utsname.machine; + } + } catch (_) { + // 设备信息读取失败时静默回退,不影响主流程。 + } + return 'unknown'; + } + + /// 打印设备信息到控制台,便于联调。 + static Future printDeviceInfo() async { + final model = await getDeviceModel(); + // ignore: avoid_print + print('运行设备: $model, 设备ID: ${TokenStorage.deviceId}'); + } +} diff --git a/lib/features/auth/data/auth_providers.dart b/lib/features/auth/data/auth_providers.dart new file mode 100644 index 0000000..79fc9e1 --- /dev/null +++ b/lib/features/auth/data/auth_providers.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../domain/auth_repository.dart'; +import 'auth_repository_impl.dart'; + +/// 认证仓库 Provider。 +/// +/// 会话级依赖,通过 ref.keepAlive() 避免页面切换时被销毁重建。 +final authRepositoryProvider = Provider((ref) { + ref.keepAlive(); + return const AuthRepositoryImpl(); +}); diff --git a/lib/features/auth/data/auth_repository_impl.dart b/lib/features/auth/data/auth_repository_impl.dart new file mode 100644 index 0000000..d5583b5 --- /dev/null +++ b/lib/features/auth/data/auth_repository_impl.dart @@ -0,0 +1,100 @@ +import '../../../core/network/api_exception.dart'; +import '../../../core/network/dio_client.dart'; +import '../../../core/storage/token_storage.dart'; +import '../domain/auth_models.dart'; +import '../domain/auth_repository.dart'; + +/// 认证仓库实现(数据层)。 +/// +/// 通过 DioClient 调用后台,将 JSON DTO 映射为领域实体,并持久化令牌。 +class AuthRepositoryImpl implements AuthRepository { + const AuthRepositoryImpl(); + + @override + Future loginByPassword({ + required String phone, + required String password, + required String deviceId, + }) async { + final data = await DioClient.post( + '/auth/login', + data: { + 'phone': phone, + 'password': password, + 'deviceId': deviceId, + 'type': 'password', + }, + ); + final result = LoginResult.fromJson(data as Map); + TokenStorage.saveTokens( + token: result.token, + refreshToken: result.refreshToken, + ); + return result; + } + + @override + Future loginBySms({ + required String phone, + required String code, + required String deviceId, + }) async { + final data = await DioClient.post( + '/auth/login', + data: { + 'phone': phone, + 'code': code, + 'deviceId': deviceId, + 'type': 'sms', + }, + ); + final result = LoginResult.fromJson(data as Map); + TokenStorage.saveTokens( + token: result.token, + refreshToken: result.refreshToken, + ); + return result; + } + + @override + Future register({ + required String phone, + required String code, + required String password, + required String deviceId, + }) async { + final data = await DioClient.post( + '/auth/register', + data: { + 'phone': phone, + 'code': code, + 'password': password, + 'deviceId': deviceId, + }, + ); + final result = LoginResult.fromJson(data as Map); + TokenStorage.saveTokens( + token: result.token, + refreshToken: result.refreshToken, + ); + return result; + } + + @override + Future sendSmsCode({ + required String phone, + required SmsScene scene, + }) async { + final resp = await DioClient.post( + '/auth/sms', + data: { + 'phone': phone, + 'scene': scene == SmsScene.register ? 'register' : 'login', + }, + ); + // 后台成功时 data 可为空,仅做类型校验以防结构异常。 + if (resp is! Map && resp != null) { + throw const ApiException(code: -1, message: '短信接口返回异常'); + } + } +} diff --git a/lib/features/auth/domain/auth_models.dart b/lib/features/auth/domain/auth_models.dart new file mode 100644 index 0000000..36bfad8 --- /dev/null +++ b/lib/features/auth/domain/auth_models.dart @@ -0,0 +1,47 @@ +/// 认证领域实体。 +/// +/// 以不可变类表达,避免 UI / 仓库直接依赖后台 DTO 结构。 +/// 因未运行 build_runner(避免改动依赖与生成产物),此处手写等价实现: +/// 所有字段 final、提供 const 构造与 copyWith,并手动实现 JSON 转换。 +class LoginResult { + const LoginResult({ + required this.token, + this.refreshToken, + this.userId, + this.phone, + }); + + final String token; + final String? refreshToken; + final String? userId; + final String? phone; + + LoginResult copyWith({ + String? token, + String? refreshToken, + String? userId, + String? phone, + }) { + return LoginResult( + token: token ?? this.token, + refreshToken: refreshToken ?? this.refreshToken, + userId: userId ?? this.userId, + phone: phone ?? this.phone, + ); + } + + factory LoginResult.fromJson(Map json) { + return LoginResult( + token: json['token'] as String, + refreshToken: json['refreshToken'] as String?, + userId: json['userId'] as String?, + phone: json['phone'] as String?, + ); + } +} + +/// 发送短信验证码的用途。 +enum SmsScene { + login, + register, +} diff --git a/lib/features/auth/domain/auth_repository.dart b/lib/features/auth/domain/auth_repository.dart new file mode 100644 index 0000000..ae29864 --- /dev/null +++ b/lib/features/auth/domain/auth_repository.dart @@ -0,0 +1,34 @@ +import 'auth_models.dart'; + +/// 认证仓库接口(领域层)。 +/// +/// 定义认证相关业务能力,具体实现位于 data 层,UI 仅依赖此抽象。 +abstract class AuthRepository { + /// 密码登录。 + Future loginByPassword({ + required String phone, + required String password, + required String deviceId, + }); + + /// 短信验证码登录。 + Future loginBySms({ + required String phone, + required String code, + required String deviceId, + }); + + /// 短信注册。 + Future register({ + required String phone, + required String code, + required String password, + required String deviceId, + }); + + /// 发送短信验证码。 + Future sendSmsCode({ + required String phone, + required SmsScene scene, + }); +} diff --git a/lib/features/auth/domain/auth_state.dart b/lib/features/auth/domain/auth_state.dart new file mode 100644 index 0000000..0f34420 --- /dev/null +++ b/lib/features/auth/domain/auth_state.dart @@ -0,0 +1,39 @@ +/// 认证控制器状态。 +/// +/// 不可变状态对象:UI 通过 ref.watch 消费,一次性提示经由 [alert] 字段传递, +/// 展示后由 consumeAlert() 置空。 +class AuthState { + const AuthState({ + this.isLoading = false, + this.isSendingCode = false, + this.countdownSeconds = 0, + this.alert, + }); + + /// 登录 / 注册进行中。 + final bool isLoading; + + /// 发送验证码进行中。 + final bool isSendingCode; + + /// 验证码倒计时剩余秒数(>0 时按钮禁用)。 + final int countdownSeconds; + + /// 一次性提示文案,为 null 表示无提示。 + final String? alert; + + AuthState copyWith({ + bool? isLoading, + bool? isSendingCode, + int? countdownSeconds, + String? alert, + bool clearAlert = false, + }) { + return AuthState( + isLoading: isLoading ?? this.isLoading, + isSendingCode: isSendingCode ?? this.isSendingCode, + countdownSeconds: countdownSeconds ?? this.countdownSeconds, + alert: clearAlert ? null : (alert ?? this.alert), + ); + } +} diff --git a/lib/features/auth/presentation/auth_controller.dart b/lib/features/auth/presentation/auth_controller.dart new file mode 100644 index 0000000..11ead31 --- /dev/null +++ b/lib/features/auth/presentation/auth_controller.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/storage/token_storage.dart'; +import '../data/auth_providers.dart'; +import '../domain/auth_models.dart'; +import '../domain/auth_state.dart'; + +/// 认证控制器。 +/// +/// 承载登录 / 注册 / 发送验证码 / 倒计时等全部业务逻辑,UI 仅触发动作并消费状态。 +/// 使用 keepAlive,避免登录流程中页面切换导致状态丢失。 +class AuthController extends Notifier { + AuthController(); + + Timer? _countdownTimer; + + @override + AuthState build() { + ref.keepAlive(); + ref.onDispose(() => _countdownTimer?.cancel()); + return const AuthState(); + } + + String get _deviceId => TokenStorage.deviceId; + + /// 密码登录。 + Future loginByPassword({ + required String phone, + required String password, + }) async { + return _run(() => ref.read(authRepositoryProvider).loginByPassword( + phone: phone, + password: password, + deviceId: _deviceId, + )); + } + + /// 短信登录。 + Future loginBySms({ + required String phone, + required String code, + }) async { + return _run(() => ref.read(authRepositoryProvider).loginBySms( + phone: phone, + code: code, + deviceId: _deviceId, + )); + } + + /// 注册。 + Future register({ + required String phone, + required String code, + required String password, + }) async { + return _run(() => ref.read(authRepositoryProvider).register( + phone: phone, + code: code, + password: password, + deviceId: _deviceId, + )); + } + + /// 发送短信验证码并启动 60s 倒计时。 + Future sendSmsCode({ + required String phone, + required SmsScene scene, + }) async { + if (state.isSendingCode || state.countdownSeconds > 0) return; + state = state.copyWith(isSendingCode: true, clearAlert: true); + try { + await ref.read(authRepositoryProvider).sendSmsCode( + phone: phone, + scene: scene, + ); + _startCountdown(); + } catch (e) { + state = state.copyWith( + isSendingCode: false, + alert: _messageOf(e), + ); + } + } + + /// 消费一次性提示。 + void consumeAlert() { + if (state.alert != null) { + state = state.copyWith(clearAlert: true); + } + } + + /// 登出。 + void logout() { + _countdownTimer?.cancel(); + TokenStorage.clear(); + state = const AuthState(); + } + + Future _run(Future Function() action) async { + state = state.copyWith(isLoading: true, clearAlert: true); + try { + await action(); + state = state.copyWith(isLoading: false); + return true; + } catch (e) { + state = state.copyWith(isLoading: false, alert: _messageOf(e)); + return false; + } + } + + void _startCountdown() { + state = state.copyWith(isSendingCode: false, countdownSeconds: 60); + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final left = state.countdownSeconds - 1; + if (left <= 0) { + timer.cancel(); + state = state.copyWith(countdownSeconds: 0); + } else { + state = state.copyWith(countdownSeconds: left); + } + }); + } + + String _messageOf(Object e) { + if (e is Exception) { + return e.toString().replaceFirst('Exception: ', ''); + } + return '操作失败,请稍后重试'; + } +} + +/// 认证控制器 Provider(会话级 keepAlive 在 build 内通过 ref.keepAlive 实现)。 +final authControllerProvider = + NotifierProvider(AuthController.new); diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart new file mode 100644 index 0000000..6102a54 --- /dev/null +++ b/lib/features/auth/presentation/login_page.dart @@ -0,0 +1,218 @@ +import 'package:flutter/cupertino.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../domain/auth_models.dart'; +import 'auth_controller.dart'; + +/// 登录页。 +/// +/// UI 仅持有文本控制器与登录方式(UI 局部状态),业务动作委托给 [authControllerProvider]。 +class LoginPage extends ConsumerStatefulWidget { + const LoginPage({super.key}); + + @override + ConsumerState createState() => _LoginPageState(); +} + +class _LoginPageState extends ConsumerState { + final _phoneController = TextEditingController(); + final _passwordController = TextEditingController(); + final _codeController = TextEditingController(); + + /// true: 验证码登录;false: 密码登录。 + bool _isSmsLogin = false; + + @override + void dispose() { + _phoneController.dispose(); + _passwordController.dispose(); + _codeController.dispose(); + super.dispose(); + } + + Future _submit() async { + final phone = _phoneController.text.trim(); + final ok = _isSmsLogin + ? await ref.read(authControllerProvider.notifier).loginBySms( + phone: phone, + code: _codeController.text.trim(), + ) + : await ref.read(authControllerProvider.notifier).loginByPassword( + phone: phone, + password: _passwordController.text, + ); + if (ok && mounted) context.go('/home'); + } + + @override + Widget build(BuildContext context) { + ref.listen(authControllerProvider.select((s) => s.alert), + (_, alert) { + if (alert != null) { + _showAlert(alert); + ref.read(authControllerProvider.notifier).consumeAlert(); + } + }); + + final state = ref.watch(authControllerProvider); + final l10n = AppLocalizations.of(context); + + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground, + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.loginTitle), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + CupertinoSlidingSegmentedControl( + groupValue: _isSmsLogin, + children: { + false: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(l10n.passwordLogin), + ), + true: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(l10n.smsLogin), + ), + }, + onValueChanged: (value) { + if (value != null) setState(() => _isSmsLogin = value); + }, + ), + const SizedBox(height: 24), + CupertinoTextField( + controller: _phoneController, + placeholder: l10n.phoneHint, + keyboardType: TextInputType.phone, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(height: 12), + if (_isSmsLogin) + _CodeField( + controller: _codeController, + countdownSeconds: state.countdownSeconds, + isSending: state.isSendingCode, + codeHint: l10n.codeHint, + getCodeLabel: l10n.getCode, + sendingLabel: l10n.sending, + onSend: () async { + await ref + .read(authControllerProvider.notifier) + .sendSmsCode( + phone: _phoneController.text.trim(), + scene: SmsScene.login, + ); + }, + ) + else + CupertinoTextField( + controller: _passwordController, + placeholder: l10n.passwordHint, + obscureText: true, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(height: 24), + CupertinoButton.filled( + onPressed: state.isLoading ? null : _submit, + child: state.isLoading + ? const CupertinoActivityIndicator() + : Text(l10n.login), + ), + const SizedBox(height: 12), + CupertinoButton( + onPressed: () => context.go('/register'), + child: Text(l10n.noAccount), + ), + ], + ), + ), + ), + ); + } + + void _showAlert(String message) { + final l10n = AppLocalizations.of(context); + showCupertinoDialog( + context: context, + builder: (_) => CupertinoAlertDialog( + title: Text(l10n.alertTitle), + content: Text(message), + actions: [ + CupertinoDialogAction( + child: Text(l10n.confirm), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } +} + +/// 验证码输入 + 发送按钮(独立小组件,避免内联冗长)。提取为私有无状态组件。 +class _CodeField extends StatelessWidget { + const _CodeField({ + required this.controller, + required this.countdownSeconds, + required this.isSending, + required this.codeHint, + required this.getCodeLabel, + required this.sendingLabel, + required this.onSend, + }); + + final TextEditingController controller; + final int countdownSeconds; + final bool isSending; + final String codeHint; + final String getCodeLabel; + final String sendingLabel; + final Future Function() onSend; + + @override + Widget build(BuildContext context) { + final counting = countdownSeconds > 0; + return Row( + children: [ + Expanded( + child: CupertinoTextField( + controller: controller, + placeholder: codeHint, + keyboardType: TextInputType.number, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const SizedBox(width: 12), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 12), + onPressed: (counting || isSending) ? null : () => onSend(), + child: Text( + counting + ? '${countdownSeconds}s' + : (isSending ? sendingLabel : getCodeLabel), + style: const TextStyle(color: CupertinoColors.activeBlue), + ), + ), + ], + ); + } +} diff --git a/lib/features/auth/presentation/register_page.dart b/lib/features/auth/presentation/register_page.dart new file mode 100644 index 0000000..113d663 --- /dev/null +++ b/lib/features/auth/presentation/register_page.dart @@ -0,0 +1,190 @@ +import 'package:flutter/cupertino.dart'; +import 'package:go_router/go_router.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../domain/auth_models.dart'; +import 'auth_controller.dart'; + +/// 注册页。 +/// +/// 业务动作委托给 [authControllerProvider],UI 仅负责输入收集与导航。 +class RegisterPage extends ConsumerStatefulWidget { + const RegisterPage({super.key}); + + @override + ConsumerState createState() => _RegisterPageState(); +} + +class _RegisterPageState extends ConsumerState { + final _phoneController = TextEditingController(); + final _codeController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _phoneController.dispose(); + _codeController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _submit() async { + final ok = await ref.read(authControllerProvider.notifier).register( + phone: _phoneController.text.trim(), + code: _codeController.text.trim(), + password: _passwordController.text, + ); + if (ok && mounted) context.go('/home'); + } + + @override + Widget build(BuildContext context) { + ref.listen(authControllerProvider.select((s) => s.alert), + (_, alert) { + if (alert != null) { + _showAlert(alert); + ref.read(authControllerProvider.notifier).consumeAlert(); + } + }); + + final state = ref.watch(authControllerProvider); + final l10n = AppLocalizations.of(context); + + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemGroupedBackground, + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.registerTitle), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + CupertinoTextField( + controller: _phoneController, + placeholder: l10n.phoneHint, + keyboardType: TextInputType.phone, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(height: 12), + _CodeField( + controller: _codeController, + countdownSeconds: state.countdownSeconds, + isSending: state.isSendingCode, + codeHint: l10n.codeHint, + getCodeLabel: l10n.getCode, + sendingLabel: l10n.sending, + onSend: () async { + await ref.read(authControllerProvider.notifier).sendSmsCode( + phone: _phoneController.text.trim(), + scene: SmsScene.register, + ); + }, + ), + const SizedBox(height: 12), + CupertinoTextField( + controller: _passwordController, + placeholder: l10n.passwordHint, + obscureText: true, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + const SizedBox(height: 24), + CupertinoButton.filled( + onPressed: state.isLoading ? null : _submit, + child: state.isLoading + ? const CupertinoActivityIndicator() + : Text(l10n.register), + ), + const SizedBox(height: 12), + CupertinoButton( + onPressed: () => context.go('/login'), + child: Text(l10n.hasAccount), + ), + ], + ), + ), + ), + ); + } + + void _showAlert(String message) { + final l10n = AppLocalizations.of(context); + showCupertinoDialog( + context: context, + builder: (_) => CupertinoAlertDialog( + title: Text(l10n.alertTitle), + content: Text(message), + actions: [ + CupertinoDialogAction( + child: Text(l10n.confirm), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + } +} + +/// 验证码输入 + 发送按钮(独立小组件)。提取为私有无状态组件避免冗长内联。 +class _CodeField extends StatelessWidget { + const _CodeField({ + required this.controller, + required this.countdownSeconds, + required this.isSending, + required this.codeHint, + required this.getCodeLabel, + required this.sendingLabel, + required this.onSend, + }); + + final TextEditingController controller; + final int countdownSeconds; + final bool isSending; + final String codeHint; + final String getCodeLabel; + final String sendingLabel; + final Future Function() onSend; + + @override + Widget build(BuildContext context) { + final counting = countdownSeconds > 0; + return Row( + children: [ + Expanded( + child: CupertinoTextField( + controller: controller, + placeholder: codeHint, + keyboardType: TextInputType.number, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const SizedBox(width: 12), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 12), + onPressed: (counting || isSending) ? null : () => onSend(), + child: Text( + counting + ? '${countdownSeconds}s' + : (isSending ? sendingLabel : getCodeLabel), + style: const TextStyle(color: CupertinoColors.activeBlue), + ), + ), + ], + ); + } +} diff --git a/lib/features/auth/presentation/splash_page.dart b/lib/features/auth/presentation/splash_page.dart new file mode 100644 index 0000000..de70f10 --- /dev/null +++ b/lib/features/auth/presentation/splash_page.dart @@ -0,0 +1,71 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/storage/token_storage.dart'; +import '../../../l10n/app_localizations.dart'; + +/// 启动页。 +/// +/// 仅负责首屏展示与路由决策(登录态判断),不含业务请求。 +class SplashPage extends ConsumerStatefulWidget { + const SplashPage({super.key}); + + @override + ConsumerState createState() => _SplashPageState(); +} + +class _SplashPageState extends ConsumerState { + bool _navigated = false; + + @override + void initState() { + super.initState(); + // 首帧绘制后做一次性的路由决策。 + // 不使用 Future.delayed 做跳转:App 进后台再回前台时计时器可能被挂起, + // 导致恢复后跳转不可靠、画面卡在第一屏。改用首帧回调 + 幂等保护。 + WidgetsBinding.instance.addPostFrameCallback((_) => _decideRoute()); + } + + void _decideRoute() { + if (_navigated || !mounted) return; + _navigated = true; + try { + final target = TokenStorage.isLoggedIn ? '/home' : '/login'; + context.go(target); + } catch (_) { + // 极端情况下的路由异常不应卡死首屏,重试一次。 + if (mounted) { + _navigated = false; + WidgetsBinding.instance.addPostFrameCallback((_) => _decideRoute()); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + return CupertinoPageScaffold( + backgroundColor: CupertinoColors.systemBackground, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + CupertinoIcons.heart_fill, + size: 64, + color: CupertinoColors.activeBlue, + ), + const SizedBox(height: 16), + Text( + l10n.appTitle, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + SizedBox(height: 8), + CupertinoActivityIndicator(), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/home_page.dart b/lib/features/home/presentation/home_page.dart new file mode 100644 index 0000000..fda9531 --- /dev/null +++ b/lib/features/home/presentation/home_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/app_localizations.dart'; + +/// 首页 Shell(含底部 Tab:首页 / 消息 / 我的)。 +/// +/// Tab 切换通过 GoRouter 的 StatefulShellRoute 管理,本组件仅渲染当前分支与底部栏。 +/// 实际 Tab 内容由对应特性模块(home / messages / profile)提供。 +class HomePage extends ConsumerWidget { + const HomePage({required this.child, super.key}); + + /// 当前选中的 Tab 子树(由路由 shell 注入)。 + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final location = GoRouterState.of(context).uri.path; + final index = switch (location) { + final p when p.startsWith('/messages') => 1, + final p when p.startsWith('/profile') => 2, + _ => 0, + }; + + return CupertinoPageScaffold( + child: Stack( + children: [ + child, + Align( + alignment: Alignment.bottomCenter, + child: CupertinoTabBar( + currentIndex: index, + onTap: (i) => _onTabTap(context, i), + items: [ + BottomNavigationBarItem( + icon: const Icon(CupertinoIcons.home), + label: l10n.tabHome, + ), + BottomNavigationBarItem( + icon: const Icon(CupertinoIcons.chat_bubble_2), + label: l10n.tabMessages, + ), + BottomNavigationBarItem( + icon: const Icon(CupertinoIcons.person), + label: l10n.tabProfile, + ), + ], + ), + ), + ], + ), + ); + } + + void _onTabTap(BuildContext context, int index) { + switch (index) { + case 0: + context.go('/home'); + case 1: + context.go('/messages'); + case 2: + context.go('/profile'); + } + } +} diff --git a/lib/features/home/presentation/home_tab.dart b/lib/features/home/presentation/home_tab.dart new file mode 100644 index 0000000..3b479fb --- /dev/null +++ b/lib/features/home/presentation/home_tab.dart @@ -0,0 +1,22 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/app_localizations.dart'; + +/// 首页 Tab 内容(特性模块:home / presentation)。 +/// +/// 当前为占位组件,后续可在此接入首页业务控制器与列表。 +class HomeTab extends ConsumerWidget { + const HomeTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text(l10n.tabHome)), + child: SafeArea( + child: Center(child: Text(l10n.homeContent)), + ), + ); + } +} diff --git a/lib/features/messages/presentation/messages_tab.dart b/lib/features/messages/presentation/messages_tab.dart new file mode 100644 index 0000000..6a9185c --- /dev/null +++ b/lib/features/messages/presentation/messages_tab.dart @@ -0,0 +1,22 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/app_localizations.dart'; + +/// 消息 Tab 内容(特性模块:messages / presentation)。 +/// +/// 当前为占位组件,后续可在此接入会话列表与未读状态控制器。 +class MessagesTab extends ConsumerWidget { + const MessagesTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text(l10n.tabMessages)), + child: SafeArea( + child: Center(child: Text(l10n.messagesContent)), + ), + ); + } +} diff --git a/lib/features/profile/presentation/profile_tab.dart b/lib/features/profile/presentation/profile_tab.dart new file mode 100644 index 0000000..d0fc0de --- /dev/null +++ b/lib/features/profile/presentation/profile_tab.dart @@ -0,0 +1,32 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../auth/presentation/auth_controller.dart'; + +/// 我的 Tab 内容(特性模块:profile / presentation)。 +/// +/// 含登出入口,登出动作委托给 authController,随后路由回登录页。 +class ProfileTab extends ConsumerWidget { + const ProfileTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar(middle: Text(l10n.tabProfile)), + child: SafeArea( + child: Center( + child: CupertinoButton( + child: Text(l10n.logout), + onPressed: () { + ref.read(authControllerProvider.notifier).logout(); + context.go('/login'); + }, + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..e4d7193 --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,266 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations of(BuildContext context) { + return Localizations.of(context, AppLocalizations)!; + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('zh'), + ]; + + /// 应用标题 + /// + /// In zh, this message translates to: + /// **'桐桐家庭关怀'** + String get appTitle; + + /// 底部导航:首页 + /// + /// In zh, this message translates to: + /// **'首页'** + String get tabHome; + + /// 底部导航:消息 + /// + /// In zh, this message translates to: + /// **'消息'** + String get tabMessages; + + /// 底部导航:我的 + /// + /// In zh, this message translates to: + /// **'我的'** + String get tabProfile; + + /// 首页占位文案 + /// + /// In zh, this message translates to: + /// **'首页内容'** + String get homeContent; + + /// 消息占位文案 + /// + /// In zh, this message translates to: + /// **'消息内容'** + String get messagesContent; + + /// 我的页退出登录按钮 + /// + /// In zh, this message translates to: + /// **'退出登录'** + String get logout; + + /// 登录页导航标题 + /// + /// In zh, this message translates to: + /// **'登录桐桐家庭关怀'** + String get loginTitle; + + /// 注册页导航标题 + /// + /// In zh, this message translates to: + /// **'注册账号'** + String get registerTitle; + + /// 登录方式:密码 + /// + /// In zh, this message translates to: + /// **'密码登录'** + String get passwordLogin; + + /// 登录方式:验证码 + /// + /// In zh, this message translates to: + /// **'验证码登录'** + String get smsLogin; + + /// 手机号输入框占位 + /// + /// In zh, this message translates to: + /// **'手机号'** + String get phoneHint; + + /// 密码输入框占位 + /// + /// In zh, this message translates to: + /// **'密码'** + String get passwordHint; + + /// 验证码输入框占位 + /// + /// In zh, this message translates to: + /// **'验证码'** + String get codeHint; + + /// 获取验证码按钮 + /// + /// In zh, this message translates to: + /// **'获取验证码'** + String get getCode; + + /// 登录按钮 + /// + /// In zh, this message translates to: + /// **'登录'** + String get login; + + /// 注册按钮 + /// + /// In zh, this message translates to: + /// **'注册并登录'** + String get register; + + /// 去注册入口 + /// + /// In zh, this message translates to: + /// **'没有账号?去注册'** + String get noAccount; + + /// 去登录入口 + /// + /// In zh, this message translates to: + /// **'已有账号?去登录'** + String get hasAccount; + + /// 通用弹窗标题 + /// + /// In zh, this message translates to: + /// **'提示'** + String get alertTitle; + + /// 通用确认按钮 + /// + /// In zh, this message translates to: + /// **'确定'** + String get confirm; + + /// 验证码发送中 + /// + /// In zh, this message translates to: + /// **'发送中'** + String get sending; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..c5c3861 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,76 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'TongTong Family Care'; + + @override + String get tabHome => 'Home'; + + @override + String get tabMessages => 'Messages'; + + @override + String get tabProfile => 'Profile'; + + @override + String get homeContent => 'Home Content'; + + @override + String get messagesContent => 'Messages Content'; + + @override + String get logout => 'Sign Out'; + + @override + String get loginTitle => 'Sign in to TongTong Family Care'; + + @override + String get registerTitle => 'Create Account'; + + @override + String get passwordLogin => 'Password'; + + @override + String get smsLogin => 'SMS Code'; + + @override + String get phoneHint => 'Phone'; + + @override + String get passwordHint => 'Password'; + + @override + String get codeHint => 'Code'; + + @override + String get getCode => 'Get Code'; + + @override + String get login => 'Sign In'; + + @override + String get register => 'Sign Up'; + + @override + String get noAccount => 'No account? Sign up'; + + @override + String get hasAccount => 'Have an account? Sign in'; + + @override + String get alertTitle => 'Notice'; + + @override + String get confirm => 'OK'; + + @override + String get sending => 'Sending'; +} diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..65c58aa --- /dev/null +++ b/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,76 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appTitle => '桐桐家庭关怀'; + + @override + String get tabHome => '首页'; + + @override + String get tabMessages => '消息'; + + @override + String get tabProfile => '我的'; + + @override + String get homeContent => '首页内容'; + + @override + String get messagesContent => '消息内容'; + + @override + String get logout => '退出登录'; + + @override + String get loginTitle => '登录桐桐家庭关怀'; + + @override + String get registerTitle => '注册账号'; + + @override + String get passwordLogin => '密码登录'; + + @override + String get smsLogin => '验证码登录'; + + @override + String get phoneHint => '手机号'; + + @override + String get passwordHint => '密码'; + + @override + String get codeHint => '验证码'; + + @override + String get getCode => '获取验证码'; + + @override + String get login => '登录'; + + @override + String get register => '注册并登录'; + + @override + String get noAccount => '没有账号?去注册'; + + @override + String get hasAccount => '已有账号?去登录'; + + @override + String get alertTitle => '提示'; + + @override + String get confirm => '确定'; + + @override + String get sending => '发送中'; +} diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb new file mode 100644 index 0000000..9a3c03d --- /dev/null +++ b/lib/l10n/intl_en.arb @@ -0,0 +1,25 @@ +{ + "@@locale": "en", + "appTitle": "TongTong Family Care", + "tabHome": "Home", + "tabMessages": "Messages", + "tabProfile": "Profile", + "homeContent": "Home Content", + "messagesContent": "Messages Content", + "logout": "Sign Out", + "loginTitle": "Sign in to TongTong Family Care", + "registerTitle": "Create Account", + "passwordLogin": "Password", + "smsLogin": "SMS Code", + "phoneHint": "Phone", + "passwordHint": "Password", + "codeHint": "Code", + "getCode": "Get Code", + "login": "Sign In", + "register": "Sign Up", + "noAccount": "No account? Sign up", + "hasAccount": "Have an account? Sign in", + "alertTitle": "Notice", + "confirm": "OK", + "sending": "Sending" +} diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb new file mode 100644 index 0000000..165c8f9 --- /dev/null +++ b/lib/l10n/intl_zh.arb @@ -0,0 +1,91 @@ +{ + "@@locale": "zh", + "appTitle": "桐桐家庭关怀", + "@appTitle": { + "description": "应用标题" + }, + "tabHome": "首页", + "@tabHome": { + "description": "底部导航:首页" + }, + "tabMessages": "消息", + "@tabMessages": { + "description": "底部导航:消息" + }, + "tabProfile": "我的", + "@tabProfile": { + "description": "底部导航:我的" + }, + "homeContent": "首页内容", + "@homeContent": { + "description": "首页占位文案" + }, + "messagesContent": "消息内容", + "@messagesContent": { + "description": "消息占位文案" + }, + "logout": "退出登录", + "@logout": { + "description": "我的页退出登录按钮" + }, + "loginTitle": "登录桐桐家庭关怀", + "@loginTitle": { + "description": "登录页导航标题" + }, + "registerTitle": "注册账号", + "@registerTitle": { + "description": "注册页导航标题" + }, + "passwordLogin": "密码登录", + "@passwordLogin": { + "description": "登录方式:密码" + }, + "smsLogin": "验证码登录", + "@smsLogin": { + "description": "登录方式:验证码" + }, + "phoneHint": "手机号", + "@phoneHint": { + "description": "手机号输入框占位" + }, + "passwordHint": "密码", + "@passwordHint": { + "description": "密码输入框占位" + }, + "codeHint": "验证码", + "@codeHint": { + "description": "验证码输入框占位" + }, + "getCode": "获取验证码", + "@getCode": { + "description": "获取验证码按钮" + }, + "login": "登录", + "@login": { + "description": "登录按钮" + }, + "register": "注册并登录", + "@register": { + "description": "注册按钮" + }, + "noAccount": "没有账号?去注册", + "@noAccount": { + "description": "去注册入口" + }, + "hasAccount": "已有账号?去登录", + "@hasAccount": { + "description": "去登录入口" + }, + "alertTitle": "提示", + "@alertTitle": { + "description": "通用弹窗标题" + }, + "confirm": "确定", + "@confirm": { + "description": "通用确认按钮" + }, + "sending": "发送中", + "@sending": { + "description": "验证码发送中" + } +} diff --git a/lib/main.dart b/lib/main.dart index c860cef..af74c54 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,46 +1,23 @@ -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:get/get.dart'; -import 'package:mmkv/mmkv.dart'; +import 'package:flutter/material.dart' show WidgetsFlutterBinding; +import 'package:flutter/widgets.dart' show runApp; -import 'page/home_page.dart'; -import 'page/login_page.dart'; -import 'page/register_page.dart'; -import 'page/splash_page.dart'; +import 'app/app.dart'; +import 'app/router/app_router.dart'; +import 'core/storage/token_storage.dart'; +import 'core/utils/device_info_util.dart'; +/// 应用入口。 +/// +/// 职责:初始化存储、打印设备信息、构建路由与根组件。不包含任何业务或 UI 逻辑。 +void main() async { + WidgetsFlutterBinding.ensureInitialized(); -void main() async{ - // must wait for MMKV to finish initialization - final rootDir = await MMKV.initialize(); - print('MMKV for flutter with rootDir = $rootDir'); + // 初始化本地存储(含超时保护,避免阻塞启动导致卡在首屏)。 + await TokenStorage.initialize() + .timeout(const Duration(seconds: 5), onTimeout: () {}); + // 设备信息打印不阻塞启动主流程。 + DeviceInfoUtil.printDeviceInfo(); - final deviceInfoPlugin = DeviceInfoPlugin(); - final deviceInfo = await deviceInfoPlugin.deviceInfo; - final allInfo = deviceInfo.data; - print('Device info: $allInfo'); - - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return GetCupertinoApp( - title: 'Family Care', - theme: CupertinoThemeData( - primaryColor: CupertinoColors.systemBlue, - scaffoldBackgroundColor: CupertinoColors.systemGroupedBackground, - ), - home: const SplashScreen(), - getPages: [ - GetPage(name: '/splash', page: () => const SplashScreen()), - GetPage(name: '/login', page: () => const LoginPage()), - GetPage(name: '/home', page: () => const MyHomePage()), - GetPage(name: '/register', page: () => const RegisterPage()), - ], - ); - } + final router = buildAppRouter(); + runApp(buildApp(router)); } diff --git a/lib/page/home_page.dart b/lib/page/home_page.dart deleted file mode 100644 index 8a4aef7..0000000 --- a/lib/page/home_page.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key}); - - @override - State createState() => _MyHomePageState(); - -} - -class _MyHomePageState extends State { - int _currentIndex = 0; - late PageController _pageController; - - @override - void initState() { - super.initState(); - _pageController = PageController(initialPage: _currentIndex); - } - - @override - void dispose() { - _pageController.dispose(); - super.dispose(); - } - - void _onTabTapped(int index) { - setState(() { - _currentIndex = index; - }); - _pageController.animateToPage( - index, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - - void _onPageChanged(int index) { - setState(() { - _currentIndex = index; - }); - } - - @override - Widget build(BuildContext context) { - return CupertinoPageScaffold( - child: Column( - children: [ - Expanded( - child: PageView( - controller: _pageController, - onPageChanged: _onPageChanged, - children: [ - _buildFirstPage(), - _buildSecondPage(), - _buildThirdPage(), - ], - ), - ), - _buildBottomNavigationBar(), - ], - ), - ); - } - - Widget _buildFirstPage() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.home, size: 64, color: CupertinoColors.activeBlue), - const SizedBox(height: 16), - const Text( - '首页', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text('这是第一个页面'), - ], - ), - ); - } - - Widget _buildSecondPage() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.chat_bubble_2, size: 64, color: CupertinoColors.activeGreen), - const SizedBox(height: 16), - const Text( - '消息', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text('这是第二个页面'), - ], - ), - ); - } - - Widget _buildThirdPage() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.person, size: 64, color: CupertinoColors.systemPurple), - const SizedBox(height: 16), - const Text( - '我的', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - const Text('这是第三个页面'), - ], - ), - ); - } - - Widget _buildBottomNavigationBar() { - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: CupertinoColors.separator, width: 0.5), - ), - ), - child: CupertinoTabBar( - currentIndex: _currentIndex, - onTap: _onTabTapped, - items: const [ - BottomNavigationBarItem( - icon: Icon(CupertinoIcons.home), - label: '首页', - ), - BottomNavigationBarItem( - icon: Icon(CupertinoIcons.chat_bubble_2), - label: '消息', - ), - BottomNavigationBarItem( - icon: Icon(CupertinoIcons.person), - label: '我的', - ), - ], - ), - ); - } -} diff --git a/lib/page/login_page.dart b/lib/page/login_page.dart deleted file mode 100644 index 94c8e80..0000000 --- a/lib/page/login_page.dart +++ /dev/null @@ -1,291 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:get/get.dart'; - -class LoginPage extends StatefulWidget { - const LoginPage({super.key}); - - @override - State createState() => _LoginPageState(); -} - -class _LoginPageState extends State { - final _formKey = GlobalKey(); - final _usernameController = TextEditingController(); - final _passwordController = TextEditingController(); - final _phoneController = TextEditingController(); - final _smsCodeController = TextEditingController(); - bool _isLoading = false; - bool _isSmsLogin = false; - bool _isCountdownRunning = false; - int _countdownSeconds = 0; - - @override - void dispose() { - _usernameController.dispose(); - _passwordController.dispose(); - _phoneController.dispose(); - _smsCodeController.dispose(); - super.dispose(); - } - - Future _handleLogin() async { - if (!_formKey.currentState!.validate()) { - return; - } - - setState(() { - _isLoading = true; - }); - - await Future.delayed(const Duration(seconds: 1)); - - setState(() { - _isLoading = false; - }); - - if (mounted) { - Get.offAllNamed('/home'); - } - } - - Future _sendSmsCode() async { - if (_phoneController.text.isEmpty) { - showCupertinoDialog( - context: context, - builder: (context) => CupertinoAlertDialog( - title: const Text('提示'), - content: const Text('请先输入手机号'), - actions: [ - CupertinoDialogAction( - child: const Text('确定'), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ); - return; - } - - setState(() { - _isCountdownRunning = true; - _countdownSeconds = 60; - }); - - await Future.delayed(const Duration(seconds: 1)); - - while (_countdownSeconds > 0 && mounted) { - await Future.delayed(const Duration(seconds: 1)); - setState(() { - _countdownSeconds--; - }); - } - - if (mounted) { - setState(() { - _isCountdownRunning = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return CupertinoPageScaffold( - navigationBar: const CupertinoNavigationBar( - middle: Text('登录'), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Icon( - CupertinoIcons.person_3, - size: 80, - color: CupertinoColors.activeBlue, - ), - const SizedBox(height: 32), - const Text( - '欢迎回来', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - _isSmsLogin ? '使用手机号验证码登录' : '请登录您的账户', - style: TextStyle( - fontSize: 16, - color: CupertinoColors.inactiveGray, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - CupertinoSlidingSegmentedControl( - groupValue: _isSmsLogin, - onValueChanged: (value) { - if (value != null) { - setState(() { - _isSmsLogin = value; - }); - } - }, - children: const { - false: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('密码登录'), - ), - true: Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text('短信登录'), - ), - }, - ), - const SizedBox(height: 24), - if (!_isSmsLogin) ...[ - CupertinoFormSection.insetGrouped( - children: [ - CupertinoTextFormFieldRow( - controller: _usernameController, - prefix: const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon(CupertinoIcons.person, size: 20), - ), - placeholder: '请输入用户名', - validator: (value) { - if (value == null || value.isEmpty) { - return '请输入用户名'; - } - return null; - }, - ), - ], - ), - const SizedBox(height: 16), - CupertinoFormSection.insetGrouped( - children: [ - CupertinoTextFormFieldRow( - controller: _passwordController, - obscureText: true, - prefix: const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon(CupertinoIcons.lock, size: 20), - ), - placeholder: '请输入密码', - validator: (value) { - if (value == null || value.isEmpty) { - return '请输入密码'; - } - if (value.length < 6) { - return '密码至少6位'; - } - return null; - }, - ), - ], - ), - ] else ...[ - CupertinoFormSection.insetGrouped( - children: [ - CupertinoTextFormFieldRow( - controller: _phoneController, - keyboardType: TextInputType.phone, - prefix: const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon(CupertinoIcons.phone, size: 20), - ), - placeholder: '请输入手机号', - validator: (value) { - if (value == null || value.isEmpty) { - return '请输入手机号'; - } - if (value.length != 11) { - return '请输入有效的手机号'; - } - return null; - }, - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: CupertinoFormSection.insetGrouped( - children: [ - CupertinoTextFormFieldRow( - controller: _smsCodeController, - keyboardType: TextInputType.number, - prefix: const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon(CupertinoIcons.shield, size: 20), - ), - placeholder: '请输入验证码', - validator: (value) { - if (value == null || value.isEmpty) { - return '请输入验证码'; - } - if (value.length != 6) { - return '验证码为6位'; - } - return null; - }, - ), - ], - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 120, - height: 50, - child: CupertinoButton.filled( - onPressed: _isCountdownRunning ? null : _sendSmsCode, - borderRadius: BorderRadius.circular(12), - padding: EdgeInsets.zero, - child: Text( - _isCountdownRunning ? '${_countdownSeconds}s' : '获取验证码', - style: const TextStyle(fontSize: 14), - ), - ), - ), - ], - ), - ], - const SizedBox(height: 32), - CupertinoButton.filled( - onPressed: _isLoading ? null : _handleLogin, - padding: const EdgeInsets.symmetric(vertical: 14), - borderRadius: BorderRadius.circular(12), - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CupertinoActivityIndicator( - radius: 10, - ), - ) - : Text( - _isSmsLogin ? '验证码登录' : '登录', - style: const TextStyle(fontSize: 17), - ), - ), - const SizedBox(height: 16), - CupertinoButton( - onPressed: () { - Get.offAndToNamed('/register'); - }, - child: const Text('还没有账户?立即注册'), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/page/register_page.dart b/lib/page/register_page.dart deleted file mode 100644 index fb7daf1..0000000 --- a/lib/page/register_page.dart +++ /dev/null @@ -1,388 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:get/get.dart'; - -class RegisterPage extends StatefulWidget { - const RegisterPage({super.key}); - - @override - State createState() => _RegisterPageState(); -} - -class _RegisterPageState extends State { - final _formKey = GlobalKey(); - final _phoneController = TextEditingController(); - final _smsCodeController = TextEditingController(); - final _passwordController = TextEditingController(); - final _confirmPasswordController = TextEditingController(); - bool _isLoading = false; - bool _isCountdownRunning = false; - int _countdownSeconds = 0; - bool _obscurePassword = true; - bool _obscureConfirmPassword = true; - - @override - void dispose() { - _phoneController.dispose(); - _smsCodeController.dispose(); - _passwordController.dispose(); - _confirmPasswordController.dispose(); - super.dispose(); - } - - Future _sendSmsCode() async { - if (_phoneController.text.isEmpty) { - showCupertinoDialog( - context: context, - builder: (context) => CupertinoAlertDialog( - title: const Text('提示'), - content: const Text('请先输入手机号'), - actions: [ - CupertinoDialogAction( - child: const Text('确定'), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ); - return; - } - - setState(() { - _isCountdownRunning = true; - _countdownSeconds = 60; - }); - - await Future.delayed(const Duration(seconds: 1)); - - while (_countdownSeconds > 0 && mounted) { - await Future.delayed(const Duration(seconds: 1)); - setState(() { - _countdownSeconds--; - }); - } - - if (mounted) { - setState(() { - _isCountdownRunning = false; - }); - } - - if (mounted) { - showCupertinoDialog( - context: context, - builder: (context) => CupertinoAlertDialog( - title: const Text('验证码已发送'), - content: const Text('请输入收到的6位验证码'), - actions: [ - CupertinoDialogAction( - child: const Text('确定'), - onPressed: () => Navigator.pop(context), - ), - ], - ), - ); - } - } - - Future _handleRegister() async { - if (!_formKey.currentState!.validate()) { - return; - } - - setState(() { - _isLoading = true; - }); - - await Future.delayed(const Duration(seconds: 1)); - - setState(() { - _isLoading = false; - }); - - if (mounted) { - showCupertinoDialog( - context: context, - builder: (context) => CupertinoAlertDialog( - title: const Text('注册成功'), - content: const Text('欢迎加入!'), - actions: [ - CupertinoDialogAction( - isDefaultAction: true, - child: const Text('确定'), - onPressed: () { - Navigator.pop(context); - Navigator.pop(context); - }, - ), - ], - ), - ); - } - } - - @override - Widget build(BuildContext context) { - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: const Text('注册'), - trailing: CupertinoButton( - padding: EdgeInsets.zero, - onPressed: () { - Get.offAndToNamed('/login'); - }, - child: const Text('取消'), - ), - ), - child: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 32), - const Icon( - CupertinoIcons.person_2_fill, - size: 80, - color: CupertinoColors.activeGreen, - ), - const SizedBox(height: 24), - const Text( - '创建新账户', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - '使用手机号快速注册', - style: TextStyle( - fontSize: 16, - color: CupertinoColors.inactiveGray, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 40), - CupertinoFormSection.insetGrouped( - header: const Text('手机号'), - children: [ - CupertinoTextFormFieldRow( - controller: _phoneController, - keyboardType: TextInputType.phone, - prefix: const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon(CupertinoIcons.phone, size: 20), - ), - placeholder: '请输入11位手机号', - validator: (value) { - if (value == null || value.isEmpty) { - return '请输入手机号'; - } - if (value.length != 11) { - return '请输入有效的手机号'; - } - return null; - }, - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: CupertinoFormSection.insetGrouped( - header: const Text('验证码'), - children: [ - CupertinoTextFormFieldRow( - controller: _smsCodeController, - keyboardType: TextInputType.number, - prefix: const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon(CupertinoIcons.shield, size: 20), - ), - placeholder: '6位验证码', - validator: (value) { - if (value == null || value.isEmpty) { - return '请输入验证码'; - } - if (value.length != 6) { - return '验证码为6位'; - } - return null; - }, - ), - ], - ), - ), - const SizedBox(width: 12), - Padding( - padding: const EdgeInsets.only(top: 30), - child: SizedBox( - width: 120, - height: 50, - child: CupertinoButton.filled( - onPressed: _isCountdownRunning ? null : _sendSmsCode, - borderRadius: BorderRadius.circular(12), - padding: EdgeInsets.zero, - child: Text( - _isCountdownRunning ? '${_countdownSeconds}s' : '获取验证码', - style: const TextStyle(fontSize: 14), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - CupertinoFormSection.insetGrouped( - header: const Text('设置密码'), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Expanded( - child: CupertinoTextFormFieldRow( - controller: _passwordController, - obscureText: _obscurePassword, - prefix: const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon(CupertinoIcons.lock, size: 20), - ), - placeholder: '请设置密码(至少6位)', - validator: (value) { - if (value == null || value.isEmpty) { - return '请输入密码'; - } - if (value.length < 6) { - return '密码至少6位'; - } - return null; - }, - ), - ), - CupertinoButton( - padding: const EdgeInsets.only(left: 8), - onPressed: () { - setState(() { - _obscurePassword = !_obscurePassword; - }); - }, - child: Icon( - _obscurePassword - ? CupertinoIcons.eye_slash - : CupertinoIcons.eye, - size: 20, - color: CupertinoColors.inactiveGray, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - CupertinoFormSection.insetGrouped( - header: const Text('确认密码'), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Expanded( - child: CupertinoTextFormFieldRow( - controller: _confirmPasswordController, - obscureText: _obscureConfirmPassword, - prefix: const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon(CupertinoIcons.lock_shield, size: 20), - ), - placeholder: '请再次输入密码', - validator: (value) { - if (value == null || value.isEmpty) { - return '请确认密码'; - } - if (value != _passwordController.text) { - return '两次输入的密码不一致'; - } - return null; - }, - ), - ), - CupertinoButton( - padding: const EdgeInsets.only(left: 8), - onPressed: () { - setState(() { - _obscureConfirmPassword = !_obscureConfirmPassword; - }); - }, - child: Icon( - _obscureConfirmPassword - ? CupertinoIcons.eye_slash - : CupertinoIcons.eye, - size: 20, - color: CupertinoColors.inactiveGray, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 32), - CupertinoButton.filled( - onPressed: _isLoading ? null : _handleRegister, - padding: const EdgeInsets.symmetric(vertical: 16), - borderRadius: BorderRadius.circular(12), - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CupertinoActivityIndicator( - radius: 10, - ), - ) - : const Text( - '立即注册', - style: TextStyle(fontSize: 17), - ), - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '已有账户?', - style: TextStyle( - color: CupertinoColors.inactiveGray, - ), - ), - CupertinoButton( - padding: EdgeInsets.zero, - onPressed: () { - Get.offAndToNamed('/login'); - }, - child: const Text('立即登录'), - ), - ], - ), - const SizedBox(height: 16), - Text( - '注册即表示您同意我们的服务条款和隐私政策', - style: TextStyle( - fontSize: 12, - color: CupertinoColors.inactiveGray, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/page/splash_page.dart b/lib/page/splash_page.dart deleted file mode 100644 index 9be26a7..0000000 --- a/lib/page/splash_page.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:get/get.dart'; - -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); - - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State with WidgetsBindingObserver { - bool _hasNavigated = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _navigateToNextPage(); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - if (state == AppLifecycleState.resumed) { - if (!_hasNavigated && mounted) { - _navigateToNextPage(); - } - } - } - - Future _navigateToNextPage() async { - if (_hasNavigated) return; - - await Future.delayed(const Duration(seconds: 2)); - - if (mounted && !_hasNavigated) { - _hasNavigated = true; - final bool isLoggedIn = false; - - if (isLoggedIn) { - Get.offAllNamed('/home'); - } else { - Get.offAllNamed('/login'); - } - } - } - - @override - Widget build(BuildContext context) { - return CupertinoPageScaffold( - backgroundColor: CupertinoColors.systemBlue, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - CupertinoIcons.person_3, - size: 100, - color: CupertinoColors.white, - ), - const SizedBox(height: 24), - const Text( - 'Family Care', - style: TextStyle( - fontSize: 32, - color: CupertinoColors.white, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - const CupertinoActivityIndicator( - radius: 12, - color: CupertinoColors.white, - ), - ], - ), - ), - ); - } -} diff --git a/lib/utils/DeviceInfoUtil.dart b/lib/utils/DeviceInfoUtil.dart deleted file mode 100644 index 5838b73..0000000 --- a/lib/utils/DeviceInfoUtil.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter/foundation.dart'; - -class DeviceInfoUtil { - static final DeviceInfoPlugin _deviceInfo = DeviceInfoPlugin(); - - /// 获取设备信息 - static Future printDeviceInfo() async { - try { - if (defaultTargetPlatform == TargetPlatform.android) { - await _printAndroidDeviceInfo(); - } else if (defaultTargetPlatform == TargetPlatform.iOS) { - await _printIosDeviceInfo(); - } else { - debugPrint('当前平台不支持: $defaultTargetPlatform'); - } - } catch (e) { - debugPrint('获取设备信息失败: $e'); - } - } - - /// 获取 Android 设备信息 - static Future _printAndroidDeviceInfo() async { - final androidInfo = await _deviceInfo.androidInfo; - - debugPrint('========== Android 设备信息 =========='); - debugPrint('品牌: ${androidInfo.brand}'); - debugPrint('制造商: ${androidInfo.manufacturer}'); - debugPrint('机型: ${androidInfo.model}'); - debugPrint('设备名称: ${androidInfo.device}'); - debugPrint('产品名: ${androidInfo.product}'); - debugPrint('硬件名: ${androidInfo.hardware}'); - debugPrint('主板: ${androidInfo.board}'); - debugPrint('序列号: ${androidInfo.serialNumber}'); - debugPrint('Android ID: ${androidInfo.id}'); - debugPrint('系统版本: ${androidInfo.version.release}'); - debugPrint('SDK 版本: ${androidInfo.version.sdkInt}'); - debugPrint('安全补丁: ${androidInfo.version.securityPatch}'); - debugPrint('API 级别: ${androidInfo.version.codename}'); - debugPrint('设备类型: ${androidInfo.isPhysicalDevice ? "物理设备" : "模拟器"}'); - debugPrint('====================================='); - } - - /// 获取 iOS 设备信息 - static Future _printIosDeviceInfo() async { - final iosInfo = await _deviceInfo.iosInfo; - - debugPrint('========== iOS 设备信息 =========='); - debugPrint('设备名称: ${iosInfo.name}'); - debugPrint('系统名称: ${iosInfo.systemName}'); - debugPrint('系统版本: ${iosInfo.systemVersion}'); - debugPrint('机型: ${iosInfo.model}'); - debugPrint('本地化机型: ${iosInfo.localizedModel}'); - debugPrint('标识符: ${iosInfo.identifierForVendor}'); - debugPrint('是否是物理设备: ${iosInfo.isPhysicalDevice ? "是" : "否"}'); - debugPrint('================================='); - } -} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e5fec67..38535f0 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,17 @@ #include "generated_plugin_registrant.h" +#include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); + flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); g_autoptr(FlPluginRegistrar) mmkv_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "MmkvLinuxPlugin"); mmkv_linux_plugin_register_with_registrar(mmkv_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 82d87ca..829cb4a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux + flutter_webrtc mmkv_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 1fe62c5..ce83a5e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,15 @@ import FlutterMacOS import Foundation import device_info_plus +import flutter_secure_storage_darwin +import flutter_webrtc import mmkv_ios +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) MMKVPlugin.register(with: registry.registrar(forPlugin: "MMKVPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 570df38..7003e32 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.flutter-io.cn" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.4" + android_id: + dependency: "direct main" + description: + name: android_id + sha256: "543bbfcf316de69d3ac36601d74eeaacd0248178a2671b00ad30d09f35bd3581" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.2+1" args: dependency: transitive description: @@ -25,6 +57,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.5" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.12.7" characters: dependency: transitive description: @@ -33,6 +129,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -49,6 +153,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.11.1" collection: dependency: transitive description: @@ -57,6 +169,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" crypto: dependency: transitive description: @@ -73,6 +193,38 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0+7.7.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" + dart_webrtc: + dependency: transitive + description: + name: dart_webrtc + sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.8.1" device_info_plus: dependency: "direct main" description: @@ -137,6 +289,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.1" + fixnum: + dependency: "direct main" + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -166,6 +326,11 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -174,6 +339,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.34" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter @@ -184,14 +405,38 @@ packages: description: flutter source: sdk version: "0.0.0" - get: + flutter_webrtc: dependency: "direct main" description: - name: get - sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a" + name: flutter_webrtc + sha256: e997161d7da3adedd3d430691b20931b0b4d96fa48bb60938d9ba0bf6fca98be url: "https://pub.flutter-io.cn" source: hosted - version: "4.7.3" + version: "1.6.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" glob: dependency: transitive description: @@ -200,6 +445,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" hooks: dependency: transitive description: @@ -208,6 +469,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -216,6 +493,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.20.3" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" jni: dependency: transitive description: @@ -232,6 +525,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.2" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.9.5" leak_tracker: dependency: transitive description: @@ -264,6 +581,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" logging: dependency: transitive description: @@ -276,10 +601,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -292,10 +617,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.flutter-io.cn" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -393,7 +718,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -456,6 +781,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" + protobuf: + dependency: "direct main" + description: + name: protobuf + sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" pub_semver: dependency: transitive description: @@ -464,6 +805,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" record_use: dependency: transitive description: @@ -472,11 +821,131 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.9" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.4" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.7" source_span: dependency: transitive description: @@ -493,6 +962,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -501,6 +978,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -509,6 +994,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.1+1" term_glyph: dependency: transitive description: @@ -521,10 +1014,18 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.11" + version: "0.7.12" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -533,14 +1034,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: @@ -549,6 +1058,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -557,6 +1074,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.3" + webrtc_interface: + dependency: transitive + description: + name: webrtc_interface + sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.1" win32: dependency: transitive description: @@ -590,5 +1131,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.5 <4.0.0" - flutter: ">=3.38.4" + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 67aed76..a5c4797 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ^3.11.5 + sdk: ^3.12.2 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -30,11 +30,50 @@ environment: dependencies: flutter: sdk: flutter - get: - dio: ^5.9.2 + # 国际化(l10n / ARB) + flutter_localizations: + sdk: flutter + mmkv: ^2.4.0 flutter_baidu_mapapi_map: ^3.9.9 + + # 跨平台 WebRTC(同时支持 Android 与 iOS) + flutter_webrtc: ^1.5.2 + + # 录制文件保存路径(app 专属目录,免运行时存储权限) + path_provider: ^2.1.5 + + # WebSocket 信令通信 + web_socket_channel: ^3.0.3 + + # 安全存储:refreshToken 存 Keychain / EncryptedSharedPreferences + flutter_secure_storage: ^11.0.0 + + # 本地存储(非敏感偏好) + shared_preferences: ^2.3.0 + + # 设备 ID 生成 + uuid: ^4.4.0 + + # 官方提供的设备信息插件,用于获取 Android ID / 设备标识 device_info_plus: ^13.1.0 + protobuf: ^6.0.0 + fixnum: ^1.1.1 + android_id: ^0.5.2+1 + + # 状态管理(v2.x,注解 + 代码生成) + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 + + # 路由 + go_router: ^14.0.0 + + # 网络请求(配合 json_annotation 进行 JSON 序列化) + dio: ^5.7.0 + + # 数据模型与不可变性 + freezed_annotation: ^2.4.4 + json_annotation: ^4.9.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. @@ -51,6 +90,11 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^6.0.0 + # 代码生成:freezed / json_serializable / riverpod_generator + build_runner: ^2.4.13 + freezed: ^2.5.7 + json_serializable: ^6.9.0 + riverpod_generator: ^2.6.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -61,6 +105,7 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + generate: true # To add assets to your application, add an assets section, like this: # assets: diff --git a/skills-lock.json b/skills-lock.json index 7bc63a3..4da1031 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -5,127 +5,169 @@ "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-add-unit-test/SKILL.md", - "computedHash": "326a2b6cb57bcb4f40203e063a1060e9549a8cea0ece1a5c9a0d39a2f2b85bc8" + "computedHash": "e5310cd56656f9db8533a612d69e201ee3644dc25f4b10e9ab38d0809440eeb2" }, "dart-build-cli-app": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-build-cli-app/SKILL.md", - "computedHash": "2c693ba718e23155fd9acd8d2e14f5c311258ed900131b2ad12fda46ae5eebfa" + "computedHash": "c8cd77f77250ad0783152e180de4fbec149c76e6b2c51c3f0d265a64c7466f0a" }, "dart-collect-coverage": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-collect-coverage/SKILL.md", - "computedHash": "36d77c4ebc2edc7ade399ae8461776dd33a461899afda4b2b9d8e7f599d2ef6c" + "computedHash": "abaf27c3fe7370e4daaea2c48f6efd8cc418042e2844649a9c2979bbfde8b7b1" }, "dart-fix-runtime-errors": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-fix-runtime-errors/SKILL.md", - "computedHash": "7b13cb0df76693d8c798432a1f86325b093a35e9687f231a012763fcad54fb2c" + "computedHash": "5d297ccaf9a34c5939c80600ff8398437c0c6bd5d343093852b65559689fae1d" }, "dart-generate-test-mocks": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-generate-test-mocks/SKILL.md", - "computedHash": "7bf12a98d63e96ed51ba077a24426be1b11a9fc508f7dfca60fc6e118c3fc923" + "computedHash": "c5132b41dd1d00949d035f6d49fcaee79e95d056eb9652d465e7349f539d16a2" }, "dart-migrate-to-checks-package": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-migrate-to-checks-package/SKILL.md", - "computedHash": "00b7fa026dcf004421650f20147d020244886321f715ff57ee38a96b3a9945bc" + "computedHash": "acb9d90dc41fa4410b523e8b60083f1d9fe1dd208c1579260d454c5ab4b3b3f5" }, "dart-resolve-package-conflicts": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-resolve-package-conflicts/SKILL.md", - "computedHash": "e25c789387152d9d0c437610caa5746e337a789bcb8e9c9887b0425b94b734ef" + "computedHash": "4f8029de67b9b2da43eea381c6a320a0da8844d927781effcce6e07ac736dffd" }, "dart-run-static-analysis": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-run-static-analysis/SKILL.md", - "computedHash": "e64ea092e216ecdc9b4a8b49b06d0b9a0b49f680c1116ada1d247b2de3fa6fe1" + "computedHash": "7b143ff93bfb118ce9d72fca56cef4b38d75bfe9044367101db09cf32e6815ad" }, "dart-setup-ffi-assets": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-setup-ffi-assets/SKILL.md", - "computedHash": "c27f5a4e79a4f291e09a9cfeab2842bb04d3570bb64c56bf309e573c3f244ef8" + "computedHash": "d1813c87bd7c556d1aeda275d4b4fa49ce3a3fc19d5d3d8c203c5b9aeaefac51" }, "dart-use-ffigen": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-use-ffigen/SKILL.md", - "computedHash": "688ec3e0218b3a96437916be5bdcaea87fe4f90ba869c3deec2f3392f1c6b626" + "computedHash": "fed0a97615cf80d8fbd59960a7a9d4497ef35af84de618e26492289924cbbcbd" }, "dart-use-pattern-matching": { "source": "dart-lang/skills", "sourceType": "github", "skillPath": "skills/dart-use-pattern-matching/SKILL.md", - "computedHash": "86ea49e8f82ebcdbfd00579c0bcaca16d3f33839c96f9d3875bee95c553daa4b" + "computedHash": "4900ea465cfec31cd83d8786491609dc94e338ec8c424a1530773b2aaa0832ff" + }, + "dart-use-primary-constructors": { + "source": "dart-lang/skills", + "sourceType": "github", + "skillPath": "skills/dart-use-primary-constructors/SKILL.md", + "computedHash": "f3a388b5b11b2d3b6b6c494e2cc0fbd6c4f670b9bdeb97b024f4d18b4c1970f5" }, "flutter-add-integration-test": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-add-integration-test/SKILL.md", - "computedHash": "9ead37fef54371fed6ad07a9ba3de7a908135a0867b05baf74c28b7095343999" + "computedHash": "4246d3ef5f21bdb7945899056b99cf3863e2bff645a132b41634caaded68da6d" }, "flutter-add-widget-preview": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-add-widget-preview/SKILL.md", - "computedHash": "1b59c009558aab7a8e4b7b101fbe535b06054c0672780e9cea7e5c98a9c48dc4" + "computedHash": "369ed3ebdc1f81ee337551ad1d1dd9ec6e768ed2bdf65d32ad442117a6ba79b6" }, "flutter-add-widget-test": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-add-widget-test/SKILL.md", - "computedHash": "c4f263c59cfe331ef42dba2aa81e25142aa4f7284518907c6b847e04502676b0" + "computedHash": "f4ea905ae155d1bca76f5431bf6ed31f31e3d40146493e7ae4285eac39ba4ffd" }, "flutter-apply-architecture-best-practices": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-apply-architecture-best-practices/SKILL.md", - "computedHash": "2b1b63214d6b153c50aacd4bbcb76ce91dece449de9e06f477f8aad3163d667f" + "computedHash": "baeb208b1cab90c559677626dbd101b96ba93f803cbed85122abdadeb3283a8b" }, "flutter-build-responsive-layout": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-build-responsive-layout/SKILL.md", - "computedHash": "f762ea2ee83d4f1f35d9093b41d2520dd024ab80c9539d4860b9ae7f2a4dc6a0" + "computedHash": "6d74a9504c4e355c4c62f6fcb6c6dc0df67b56dd6616bea199e4e58f75b7729b" }, "flutter-fix-layout-issues": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-fix-layout-issues/SKILL.md", - "computedHash": "1aaba922c951159118f9b141788f7bb7b8167bdc0bd5c6f0738ebb90c29b9532" + "computedHash": "b2f9789451224e6df8d1d7ac63854c2f1beb30fa35d13ea06fbae9ba2b1b0a7f" }, "flutter-implement-json-serialization": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-implement-json-serialization/SKILL.md", - "computedHash": "0eed26f25308d78c6a9036ec936db93342f064d64f2cbac5e0e8648ba6b9db3f" + "computedHash": "c2cf46854472a452dafa11f862f2bca3d9fe7286a5bb45d85b1efbcebc74b74e" }, "flutter-setup-declarative-routing": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-setup-declarative-routing/SKILL.md", - "computedHash": "2341440c8bce0f176663695eaa71f9f7d7c967772a1dcd0d833c9778504b227e" + "computedHash": "4c2ed2fd729230be581b15d84741688e206632fb2b38af320060cd3518b91179" }, "flutter-setup-localization": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-setup-localization/SKILL.md", - "computedHash": "671bf254ca8f3172b6bda5287d9e756e9fd5b49165a815f710c5b707ebaa38f8" + "computedHash": "fc0811b1b775c52c1b8f7df600f5c52b83029aea0586b8cc55169812affd408f" }, "flutter-use-http-package": { - "source": "flutter/skills", + "source": "flutter/agent-plugins", "sourceType": "github", "skillPath": "skills/flutter-use-http-package/SKILL.md", - "computedHash": "17efcf4da9933afaf9c1d41940bd6b92f1ae73dcae4bf6c59cfe24f7e487ee9f" + "computedHash": "bd169b5cee731751f3b32f43c94cfe9495fc8e6c3eb621b785c69e38b77d0e19" + }, + "kotlin-backend-jpa-entity-mapping": { + "source": "Kotlin/kotlin-agent-skills", + "sourceType": "github", + "skillPath": "skills/kotlin-backend-jpa-entity-mapping/SKILL.md", + "computedHash": "7829efe6ac903bbd9bf2acee6dfdf9b0b238c4fb5d3c6265e543a13dc2af72aa" + }, + "kotlin-tooling-agp9-migration": { + "source": "Kotlin/kotlin-agent-skills", + "sourceType": "github", + "skillPath": "skills/kotlin-tooling-agp9-migration/SKILL.md", + "computedHash": "83e8850784e288d7179fb5b7b0c7800fd1e428c7ed35f09cbb5a9bdcf4de0bea" + }, + "kotlin-tooling-cocoapods-spm-migration": { + "source": "Kotlin/kotlin-agent-skills", + "sourceType": "github", + "skillPath": "skills/kotlin-tooling-cocoapods-spm-migration/SKILL.md", + "computedHash": "5200d3e9970ab2f36eae86135ba30d7c19f7e9847415e6bd57122a22d2d59c17" + }, + "kotlin-tooling-immutable-collections-0-5-x-migration": { + "source": "Kotlin/kotlin-agent-skills", + "sourceType": "github", + "skillPath": "skills/kotlin-tooling-immutable-collections-0-5-x-migration/SKILL.md", + "computedHash": "6ec2ddc3efa4ea17471033719a807f9e37787384e0d2b60008b261ea3e18db32" + }, + "kotlin-tooling-java-to-kotlin": { + "source": "Kotlin/kotlin-agent-skills", + "sourceType": "github", + "skillPath": "skills/kotlin-tooling-java-to-kotlin/SKILL.md", + "computedHash": "732538daa557cc92224ea4f1ebd088e55b09fa1117859f326c877a3083a74cc9" + }, + "kotlin-tooling-native-build-performance": { + "source": "Kotlin/kotlin-agent-skills", + "sourceType": "github", + "skillPath": "skills/kotlin-tooling-native-build-performance/SKILL.md", + "computedHash": "e7d583298b420d608daf294530c211b001ea1af4162b2e73dd00ec275c617fb8" } } } diff --git a/test/widget_test.dart b/test/widget_test.dart index e0f8cf5..365968a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,30 @@ -// This is a basic Flutter widget test. +// 应用启动烟雾测试。 // -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. +// 验证 CupertinoApp.router + ProviderScope + GoRouter 能正常构建并渲染首屏。 +// 重构后入口为 buildApp(buildAppRouter()),不再存在默认模板的 MyApp / Counter。 +// +// 注意:TokenStorage 依赖 MMKV,需在测试环境先 initialize(真实运行在 main 中完成)。 -import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ttstd_family_care/main.dart'; +import 'package:ttstd_family_care/app/app.dart'; +import 'package:ttstd_family_care/app/router/app_router.dart'; +import 'package:ttstd_family_care/core/storage/token_storage.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + setUpAll(() async { + // 初始化 MMKV,避免启动页路由决策时访问未就绪的存储。 + await TokenStorage.initialize(); + }); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + testWidgets('应用可正常构建并渲染首屏', (tester) async { + // 构建真实应用根组件(ProviderScope + CupertinoApp.router)。 + await tester.pumpWidget(buildApp(buildAppRouter())); + // 等待启动页 2s 路由决策定时器与导航动画完成。 + await tester.pumpAndSettle(const Duration(seconds: 5)); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + // 应用根组件成功挂载(整棵组件树的根始终为 CupertinoApp)。 + expect(find.byType(CupertinoApp), findsOneWidget); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index ec22428..6c59d9f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,15 @@ #include "generated_plugin_registrant.h" +#include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterWebRTCPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); MmkvWin32PluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("MmkvWin32Plugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 131a9c9..3bdf77e 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_windows + flutter_webrtc mmkv_win32 )