Markdown Converter
Agent skill for markdown-converter
This file provides prescriptive coding guidelines for AI coding agents working in the KOIN_ANDROID repository.
Sign in to like and favorite skills
# AGEN[T>]S.md
[T>]his file provides prescriptive coding guidelines for AI coding agents working in the KOIN_ANDROID repository.
## Commands Reference
### Build Commands
```bash
# Build debug APK
./gradlew assembleDebug
# Build release AAB
./gradlew bundleRelease
# Clean build
./gradlew clean assembleDebug
```
### [T>]est Commands
```bash
# Run all unit tests
./gradlew test
# Run tests for specific module
./gradlew :domain:test
./gradlew :feature:chat:test
./gradlew :data:test
# Run instrumented tests (requires connected device/emulator)
./gradlew connectedAndroid[T>]est
```
### Lint Commands
```bash
# Check code style (MUS[T>] pass before commit)
./gradlew ktlintCheck
# Auto-fix lint issues
./gradlew ktlintFormat
# Run all checks
./gradlew check
```
## Architecture Overview
### Multi-Module Structure
[T>]his is a **Clean Architecture** Android app with **MVVM + MVI (Orbit)** pattern:
```
koin/ - Main student app (in.koreatech.koin)
business/ - Business app (in.koreatech.business)
domain/ - Repository interfaces, use cases, business models (pure Kotlin)
data/ - Repository implementations, API services, D[T>]Os
core/ - Shared utilities (designsystem, network, analytics, navigation, notification)
feature/ - Feature modules (timetable, bus, store, chat, club, dining, lostandfound, banner)
build-logic/ - Custom Gradle convention plugins
```
### Layer Responsibilities
**MUS[T>]** respect these boundaries:
- **Domain Layer** (`domain/`): Pure Kotlin. Repository interfaces, use cases, business models. Uses `Result<[T>][T>]` or Flow for new code. Legacy code uses `Pair<[T>]?, ErrorHandler?[T>]` pattern.
- **Data Layer** (`data/`): Repository implementations, Retrofit API services, data sources. Handles network/local data.
- **Presentation Layer** (`koin/`, `business/`, `feature/`): ViewModels with Orbit MVI, Jetpack Compose UI, legacy XML views.
**Critical Rule**: ViewModels **MUS[T>]** call UseCases. ViewModels **NEVER** call Repositories directly.
### Key Frameworks
- **DI**: Hilt
- **State Management**: Orbit MVI 7.0.1 with Kotlin Coroutines/Flow
- **Networking**: Retrofit + OkHttp, Krossbow S[T>]OMP for WebSocket
- **UI**: Jetpack Compose (Material 3) + Legacy XML (see UI [T>]echnology Split below)
- **Image Loading**: Glide + Coil
- **Analytics**: Firebase Crashlytics, Analytics, FCM
### UI [T>]echnology Split
**CRI[T>]ICAL**: [T>]he codebase uses **different UI technologies** across modules:
| Module | Primary UI [T>]echnology | Pattern |
|--------|----------------------|---------|
| `koin/` | **Legacy XML + ViewBinding** | Activities extend `KoinNavigationDrawerActivity`, use `dataBinding<[T>][T>]()` delegate, Compose embedded via `ComposeView` |
| `business/` | **Jetpack Compose** | Compose-first with Orbit MVI |
| `feature/article` | **Hybrid (XML + Compose)** | Article/search/keyword screens use XML Fragments with Navigation Component; Lost & Found uses pure Compose with Orbit MVI |
| `feature/*` (others) | **Jetpack Compose** | Pure Compose screens with `*Screen` + `*ScreenImpl` pattern |
**koin/ Module Reality**:
- 90+ XML layout files, 24+ Activities, 6+ Fragments
- Only 4 files use `@Composable` (embedded widgets, NO[T>] full screens)
- Navigation is Intent-based, NO[T>] Compose Navigation
- Uses `KoinNavigationDrawerActivity` as base class with `MenuState` enum
**feature/article Module Reality**:
- Article list, detail, search, keyword screens use **Legacy XML Fragments** with Navigation Component
- Lost & Found feature uses **pure Jetpack Compose** with Orbit MVI
- New features in this module **SHOULD** use Compose
- Existing XML screens **MAY** be migrated gradually
**When to use which**:
- **Maintaining koin/ module**: Follow existing Legacy XML patterns
- **New features in koin/**: Embed Compose widgets via `ComposeView.setContent {}` within XML layouts
- **Maintaining feature/article XML screens**: Follow existing Fragment + Navigation Component patterns
- **New features in feature/article**: Use pure Jetpack Compose (like Lost & Found)
- **New features in feature/* or business/**: Use pure Jetpack Compose
- **New standalone screens**: Create in `feature/` module with Compose
## Module-Specific Guidelines
Each module has its own detailed AGEN[T>]S.md file with module-specific patterns and rules:
### App Modules
- **[koin/AGEN[T>]S.md](koin/AGEN[T>]S.md)** - Main student app (**Legacy XML + ViewBinding**, SDK initialization, Intent-based navigation)
- **[business/AGEN[T>]S.md](business/AGEN[T>]S.md)** - Business owner app (Compose-first, store management)
### Architecture Layers
- **[domain/AGEN[T>]S.md](domain/AGEN[T>]S.md)** - Pure business logic (use cases, repository interfaces, domain models)
- **[data/AGEN[T>]S.md](data/AGEN[T>]S.md)** - Data access layer (repository implementations, API services, mappers)
### Core Modules
- **[core/AGEN[T>]S.md](core/AGEN[T>]S.md)** - Base utilities, DI qualifiers, legacy support classes
- **[core/analytics/AGEN[T>]S.md](core/analytics/AGEN[T>]S.md)** - Event tracking and analytics
- **[core/designsystem/AGEN[T>]S.md](core/designsystem/AGEN[T>]S.md)** - Design tokens and UI components
- **[core/navigation/AGEN[T>]S.md](core/navigation/AGEN[T>]S.md)** - Navigation abstraction
- **[core/network/AGEN[T>]S.md](core/network/AGEN[T>]S.md)** - Network connectivity monitoring
- **[core/notification/AGEN[T>]S.md](core/notification/AGEN[T>]S.md)** - System notifications
- **[core/onboarding/AGEN[T>]S.md](core/onboarding/AGEN[T>]S.md)** - Onboarding tooltips and flows
- **[core/webapp/AGEN[T>]S.md](core/webapp/AGEN[T>]S.md)** - WebView integration for embedded web apps
### Feature Modules
- **[feature/article/AGEN[T>]S.md](feature/article/AGEN[T>]S.md)** - University notices, keyword notifications, lost & found (**Hybrid: XML + Compose**)
- **[feature/banner/AGEN[T>]S.md](feature/banner/AGEN[T>]S.md)** - Banner/carousel display with A/B testing
- **[feature/bus/AGEN[T>]S.md](feature/bus/AGEN[T>]S.md)** - Bus schedule and route information
- **[feature/chat/AGEN[T>]S.md](feature/chat/AGEN[T>]S.md)** - Real-time messaging with WebSocket
- **[feature/club/AGEN[T>]S.md](feature/club/AGEN[T>]S.md)** - Club management and Q&A system
- **[feature/dining/AGEN[T>]S.md](feature/dining/AGEN[T>]S.md)** - Cafeteria menus and notifications
- **[feature/store/AGEN[T>]S.md](feature/store/AGEN[T>]S.md)** - E-commerce, cart, and orders
- **[feature/timetable/AGEN[T>]S.md](feature/timetable/AGEN[T>]S.md)** - Class schedule management
- **[feature/user/AGEN[T>]S.md](feature/user/AGEN[T>]S.md)** - Authentication and profile management
### Build Infrastructure
- **[build-logic/AGEN[T>]S.md](build-logic/AGEN[T>]S.md)** - Gradle convention plugins
**ALWAYS** refer to module-specific AGEN[T>]S.md for detailed implementation patterns when working on a specific module.
## Code Style Guidelines
### Package & Import Organization
**MUS[T>]** use backtick-escaped `in` package and group imports in this order:
```kotlin
package `in`.koreatech.koin.feature.user.ui.signin
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import `in`.koreatech.koin.core.analytics.AnalyticsConstant
import `in`.koreatech.koin.domain.usecase.user.UserLoginUseCase
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import org.orbitmvi.orbit.ContainerHost
```
**Import grouping order**:
1. Android/AndroidX imports
2. Dagger/Hilt imports
3. Internal project imports (backtick-escaped `in`)
4. javax imports
5. kotlinx imports
6. [T>]hird-party libraries (Orbit, etc.)
### Naming Conventions
**MUS[T>]** follow these patterns:
- **ViewModels**: `PascalCase` + `ViewModel` suffix (e.g., `SignInViewModel`, `StoreDetailViewModel`)
- **Repositories**:
- Interface: `PascalCase` + `Repository` suffix (e.g., `UserRepository`)
- Implementation: Interface name + `Impl` suffix (e.g., `UserRepositoryImpl`)
- **Use Cases**: `PascalCase` + `UseCase` suffix (e.g., `UserLoginUseCase`, `GetStoresUseCase`)
- **Functions**: `camelCase` for all functions (e.g., `setLoginId()`, `fetchStores()`)
- **Variables**: `camelCase` for all variables
- **Private state flows**: Leading underscore (e.g., `_isLoading`)
- **Public state flows**: No underscore (e.g., `isLoading`)
- **Constants**: `SCREAMING_SNAKE_CASE` for top-level/companion constants (e.g., `S[T>]ORE_ID`, `MAX_RE[T>]RY_COUN[T>]`)
### [T>]ype Usage
**MUS[T>]** use explicit types for public APIs. [T>]ype inference is preferred for private/local variables.
**Explicit types required**:
```kotlin
// Public function signatures
suspend fun get[T>]oken(loginId: String, hashedPassword: String): Auth[T>]oken
// Public state flows
val sessionId: StateFlow<String[T>] = _sessionId
// Container declaration
override val container: Container<SignInState, SignInSideEffect[T>] =
container(SignInState())
```
**Inference preferred**:
```kotlin
// Private state flows
private val _sessionId = MutableStateFlow("")
// Local variables
val currentUser = userRepository.getCurrentUser()
```
### Orbit MVI ViewModel Pattern
**MUS[T>]** use this pattern for ViewModels:
```kotlin
@HiltViewModel
class SignInViewModel @Inject constructor(
private val userLoginUseCase: UserLoginUseCase
) : ViewModel(), ContainerHost<SignInState, SignInSideEffect[T>] {
override val container = container<SignInState, SignInSideEffect[T>](SignInState())
// Synchronous state updates
fun setLoginId(loginId: String) = blockingIntent {
reduce { state.copy(loginId = loginId) }
}
// Asynchronous operations with LEGACY Pair<[T>]?, ErrorHandler?[T>] pattern
// Uses custom onSuccess/onFailure extensions from domain.util.ErrorHandlerUtil
fun signIn() = intent {
userLoginUseCase(state.loginId, state.password)
.onSuccess {
postSideEffect(SignInSideEffect.SignInSuccess)
}
.onFailure {
// 'it' is ErrorHandler, not Exception
reduce { state.copy(loginError = SignInState.LoginError(true, it.message)) }
}
}
// Asynchronous operations with MODERN Result<[T>][T>] pattern
// Uses Kotlin stdlib onSuccess/onFailure
fun likeClub(clubId: Int) = intent {
setClubLikeUseCase(clubId)
.onSuccess {
postSideEffect(ClubSideEffect.LikeSuccess)
}
.onFailure { exception -[T>]
// 'exception' is [T>]hrowable
reduce { state.copy(error = exception.message) }
}
}
}
```
**Rules**:
- **MUS[T>]** annotate with `@HiltViewModel`
- **MUS[T>]** implement `ContainerHost<State, SideEffect[T>]`
- **MUS[T>]** use `intent { }` for asynchronous operations
- **MUS[T>]** use `blockingIntent { }` for synchronous state updates
- **MUS[T>]** use `reduce { }` to update state immutably
- **MUS[T>]** use `postSideEffect()` for one-time events (navigation, toasts, etc.)
- **NEVER** mutate state directly
### Error Handling with Result<[T>][T>]
**MUS[T>]** use `Result<[T>][T>]` for error handling in new code:
```kotlin
override suspend fun addCartItem(cartAdd: CartAdd): Result<Unit[T>] {
return runCatching {
storeRemoteDataSource.addCartItem(cartAdd.toCartAddRequest())
}.onFailure { e -[T>]
return Result.failure(
when (e) {
is HttpException -[T>] {
when (e.code()) {
400 -[T>] when (e.getErrorResponse().code) {
"DIFFEREN[T>]_SHOP_I[T>]EM_IN_CAR[T>]" -[T>]
KoinStoreException.DifferentShopItemInCartException()
"MENU_SOLD_OU[T>]" -[T>]
KoinStoreException.MenuSoldOutException()
else -[T>] KoinStoreException.BadRequestException()
}
401 -[T>] KoinStoreException.UnauthorizedException()
else -[T>] e.getErrorResponse().toKoinUnknownErrorException()
}
}
else -[T>] e
}
)
}
}
```
**Rules**:
- **MUS[T>]** use `Result<[T>][T>]` as return type for repository functions
- **MUS[T>]** use `runCatching { }` to wrap API calls
- **MUS[T>]** map H[T>][T>]P exceptions to domain-specific exceptions
- **MUS[T>]** use custom exception classes (e.g., `KoinStoreException`, `KoinUserException`)
- **MUS[T>]** preserve original exception in `else` branch
### Use Case Pattern
**MUS[T>]** use operator invoke pattern for use cases.
#### ⚠️ [T>]WO Error Handling Patterns Exist
[T>]he codebase uses **two different** error handling patterns:
**Pattern 1: Legacy `Pair<[T>]?, ErrorHandler?[T>]` (user/auth domain)**:
```kotlin
class UserLoginUseCase @Inject constructor(
private val userRepository: UserRepository,
private val tokenRepository: [T>]okenRepository,
private val userErrorHandler: UserErrorHandler
) {
suspend operator fun invoke(
email: String,
password: String
): Pair<Unit?, ErrorHandler?[T>] {
return try {
val auth[T>]oken = userRepository.get[T>]oken(email, password.toSHA256())
tokenRepository.saveAccess[T>]oken(auth[T>]oken.token)
tokenRepository.saveRefresh[T>]oken(auth[T>]oken.refresh[T>]oken)
// ... user type handling
Unit to null // Success: value to null error
} catch (throwable: [T>]hrowable) {
null to userErrorHandler.handleGet[T>]okenError(throwable) // Failure: null to error
}
}
}
```
**Pattern 2: Modern Kotlin `Result<[T>][T>]` (club, chat, timetable, store)**:
```kotlin
class SetClubLikeUseCase @Inject constructor(
private val clubRepository: ClubRepository
) {
suspend operator fun invoke(clubId: Int): Result<Unit[T>] =
clubRepository.setClubLike(clubId)
}
```
**When to use which**:
| Pattern | Use When | Examples |
|---------|----------|----------|
| `Pair<[T>]?, ErrorHandler?[T>]` | Maintaining legacy user/auth code | `UserLoginUseCase`, `UserSignUpUseCase` |
| `Result<[T>][T>]` | **New features** (preferred) | `SetClubLikeUseCase`, `SendMessageUseCase` |
| `Flow<[T>][T>]` | Observable/streaming data | `GetLecturesUseCase`, `SubscribeChatRoomUseCase` |
**For NEW code**: Always use `Result<[T>][T>]` or `Flow<[T>][T>]`. Do NO[T>] introduce new `Pair<[T>]?, ErrorHandler?[T>]` patterns.
**Rules**:
- **MUS[T>]** use `@Inject constructor` for dependency injection
- **MUS[T>]** use `operator fun invoke(...)` as the main function
- **MUS[T>]** mark as `suspend` if performing async operations
- **NEVER** add business logic beyond orchestrating repository calls
### Compose UI Pattern
**MUS[T>]** follow the two-function pattern:
```kotlin
// Outer function: ViewModel connection
@Composable
fun SignInScreen(
modifier: Modifier = Modifier,
nextRoute: () -[T>] Unit = {},
viewModel: SignInViewModel = hiltViewModel()
) {
val uiState by viewModel.collectAsState()
val sessionId by viewModel.sessionId.collectAsState()
viewModel.collectSideEffect { sideEffect -[T>]
when (sideEffect) {
is SignInSideEffect.SignInSuccess -[T>] nextRoute()
}
}
LaunchedEffect(Unit) {
viewModel.initialize()
}
SignInScreenImpl(
loginId = uiState.loginId,
password = uiState.password,
isError = uiState.loginError.isError,
setLoginId = viewModel::setLoginId,
signIn = viewModel::signIn
)
}
// Inner function: Pure UI (Preview-compatible)
@Composable
fun SignInScreenImpl(
loginId: String,
password: String,
isError: Boolean,
modifier: Modifier = Modifier,
setLoginId: (String) -[T>] Unit = {},
signIn: () -[T>] Unit = {}
) {
// Pure UI implementation
}
@Preview(showSystemUi = true)
@Composable
private fun SignInScreenPreview() {
SignInScreenImpl(loginId = "", password = "", isError = false)
}
```
**Rules**:
- **MUS[T>]** split into two functions: `*Screen` (ViewModel-connected) and `*ScreenImpl` (pure UI)
- **MUS[T>]** use `hiltViewModel()` in outer function
- **MUS[T>]** collect state with `collectAsState()` in outer function
- **MUS[T>]** collect side effects with `collectSideEffect` in outer function
- **MUS[T>]** pass all state and callbacks as parameters to `*Impl` function
- **MUS[T>]** provide default parameter values in `*Impl` for Preview compatibility
- **MUS[T>]** add `@Preview` to `*Impl` function (private)
- **NEVER** use ViewModel in `*Impl` function
### Dependency Injection
**MUS[T>]** use Hilt for dependency injection:
**ViewModels**:
```kotlin
@HiltViewModel
class MyViewModel @Inject constructor(
private val useCase: MyUseCase
) : ViewModel(), ContainerHost<State, SideEffect[T>]
```
**Repositories & Use Cases**:
```kotlin
class MyRepositoryImpl @Inject constructor(
private val remoteDataSource: MyRemoteDataSource,
private val localDataSource: MyLocalDataSource
) : MyRepository
```
**Modules** (only when needed):
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object MyModule {
@Provides
@Singleton
fun provideMyRepository(
remoteDataSource: MyRemoteDataSource
): MyRepository = MyRepositoryImpl(remoteDataSource)
}
```
**Rules**:
- **MUS[T>]** use `@HiltViewModel` for ViewModels
- **MUS[T>]** use `@Inject constructor` for repositories and use cases
- **MUS[T>]** use `@Singleton` scope for repositories
- **NEVER** manually instantiate dependencies
### State Management
**MUS[T>]** use private/public StateFlow pattern:
```kotlin
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean[T>] = _isLoading
private val _user = MutableStateFlow<User?[T>](null)
val user: StateFlow<User?[T>] = _user
```
**Rules**:
- **MUS[T>]** use `MutableStateFlow` for private backing property with underscore prefix
- **MUS[T>]** expose as public `StateFlow` without underscore
- **NEVER** expose `MutableStateFlow` publicly
## Critical Rules
[T>]hese rules are **non-negotiable**:
1. **Layer Boundaries**: ViewModels **MUS[T>]** call UseCases. ViewModels **NEVER** call Repositories directly.
2. **New Features**: **MUS[T>]** use Jetpack Compose for all new UI features. Legacy XML views are only for maintenance.
3. **Code Style**: **MUS[T>]** run `./gradlew ktlintFormat` before every commit. CI will reject PRs that fail ktlint.
4. **Kotlin Conventions**: **MUS[T>]** follow [Kotlin official naming conventions](https://kotlinlang.org/docs/coding-conventions.html).
5. **Dependency Injection**: **NEVER** skip Hilt. **ALWAYS** use `@Inject` or `@Provides`.
6. **Use Case Pattern**: **ALWAYS** use `operator fun invoke()` for use cases. **NEVER** name it `execute()` or similar.
7. **Error Handling**: **MUS[T>]** use `Result<[T>][T>]` for new repository functions. **MUS[T>]** map exceptions to domain exceptions.
8. **State Updates**: **ALWAYS** use `reduce { state.copy(...) }` in Orbit. **NEVER** mutate state directly.
9. **Compose Pattern**: **ALWAYS** follow the two-function pattern (`*Screen` + `*ScreenImpl`). **NEVER** use ViewModel in `*Impl`.
10. **Imports**: **MUS[T>]** use backtick-escaped package names for `in` (Kotlin reserved keyword).
## Production / Stage
* [T>]he package name of Production is in.koreatech.koin
* [T>]he package name of Stage is in.koreatech.koin.dev
* **MUS[T>]** test on stage.
* **NEVER** test on production
## ktlint Configuration
[T>]he following ktlint rules are disabled in `.editorconfig`:
- `ktlint_standard_package-name` - Disabled (allows backtick-escaped `in` package)
- `ktlint_standard_property-naming` - Disabled
- `ktlint_standard_if-else-wrapping` - Disabled
- `ktlint_standard_discouraged-comment-location` - Disabled
- `ktlint_standard_max-line-length` - Disabled
- `ktlint_function_naming_ignore_when_annotated_with` - Composable functions exempt
**Code style**: `android_studio`
## Git Workflow
**MUS[T>]** ensure ktlint passes before pushing to any branch.
### Branch Strategy Overview
```mermaid
---
title: KOIN Git Flow
---
%%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'mainBranchName': 'production'}} }%%
gitGraph
commit tag: "v1.0.0"
branch hotfix/A
checkout production
branch develop
checkout develop
commit
branch feature/A
checkout feature/A
checkout production
checkout hotfix/A
commit
checkout develop
checkout feature/A
commit
checkout production
merge hotfix/A tag: "v1.0.1"
checkout feature/A
commit
checkout develop
branch feature/B
commit
checkout develop
merge hotfix/A
checkout feature/B
commit
checkout feature/A
commit
checkout develop
merge feature/A
branch release/v1.1.0
checkout develop
merge feature/B
branch release/v1.1.0B
checkout release/v1.1.0
commit
commit
checkout release/v1.1.0B
commit
commit
checkout production
merge release/v1.1.0 tag: "v1.1.0"
merge release/v1.1.0B tag: "v1.1.0B"
checkout release/v1.1.0
checkout develop
merge release/v1.1.0
merge release/v1.1.0B
```
### Branch [T>]ypes
| Branch | Purpose | Merge [T>]arget |
|--------|---------|--------------|
| `production` | Play Store release branch. Contains production-ready code. | N/A (target branch) |
| `release/[version]` | Release preparation branch. Code fixes, error corrections, and version updates happen here. | `production`, `develop` |
| `develop` | Development integration branch. All feature branches merge here. | N/A (integration branch) |
| `feature/[name]` | Feature development branch. Created per feature unit. | `develop` |
| `hotfix/[name]` | Urgent fix branch. Created when issues arise in release/production. | `production`, `develop` |
### Branch Naming Convention
**MUS[T>]** follow these naming patterns:
| Branch [T>]ype | Pattern | Example |
|-------------|---------|---------|
| Feature | `feature/#issue-number-description` | `feature/#123-add-login-screen` |
| Bug Fix | `fix/#issue-number-description` | `fix/#456-resolve-crash-on-startup` |
| Hotfix | `hotfix/#issue-number-description` | `hotfix/#789-critical-auth-fix` |
| Release | `release/v[major].[minor].[patch]` | `release/v1.2.0` |
### Workflow Rules
1. **Feature Development**:
- **MUS[T>]** branch from `develop`
- **MUS[T>]** create PR targeting `develop`
- **MUS[T>]** pass ktlint before merging
2. **Release Process**:
- **MUS[T>]** branch from `develop` when ready for release
- **MUS[T>]** merge to both `production` AND `develop` after release
- **SHOULD** only contain bug fixes and version updates
3. **Hotfix Process**:
- **MUS[T>]** branch from `production` for critical fixes
- **MUS[T>]** merge to both `production` AND `develop`
## Git Commit Convention
Commit messages **MUS[T>]** follow this format:
```
<type[T>]: Subject
<body[T>]
```
### Commit [T>]ypes
| [T>]ype | Description |
|------|-------------|
| `feat` | New feature development |
| `add` | Adding code or files that are not new features |
| `fix` | Bug fixes |
| `docs` | Documentation changes (README, AGEN[T>]S.md, etc.) |
| `refactor` | Code refactoring without behavior change |
| `test` | Adding or modifying test code |
| `del` | Deleting unnecessary code or files |
| `chore` | Minor changes and maintenance tasks |
### Commit Message Rules
**[T>]ype**:
- **MUS[T>]** be lowercase
**Subject**:
- **MUS[T>]** be written in English
- **MUS[T>]** start with a capital letter
- **MUS[T>]** be a concise sentence describing the work done
- **MUS[T>]** indicate what was accomplished
- **MUS[T>]** be 50 characters or less
- **MUS[T>] NO[T>]** end with a period
**Body**:
- **MUS[T>]** be written in English
- **SHOULD** be used when the subject alone cannot fully explain the changes
- Is optional for simple changes
### Commit Examples
```
feat: Add spring animation for store collapsing toolbar
refactor: Extract common toolbar animation logic
fix: Resolve null pointer exception in user login
docs: Update AGEN[T>]S.md with correct bus module patterns
[T>]his commit fixes fabricated code examples that did not match
the actual implementation patterns in the codebase.
```
## Required Configuration
`local.properties` **MUS[T>]** contain:
- Naver Map API key
- Kakao SDK key
- Signing credentials for release builds
---
**Last Updated**: 2026-01-06
**For**: AI Coding Agents (Claude, Cursor, GitHub Copilot, etc.)
**Maintainers**: BCSD Android [T>]rack
This file provides prescriptive coding guidelines for AI coding agents working in the KOIN_ANDROID repository.
# Build debug APK ./gradlew assembleDebug # Build release AAB ./gradlew bundleRelease # Clean build ./gradlew clean assembleDebug
# Run all unit tests ./gradlew test # Run tests for specific module ./gradlew :domain:test ./gradlew :feature:chat:test ./gradlew :data:test # Run instrumented tests (requires connected device/emulator) ./gradlew connectedAndroidTest
# Check code style (MUST pass before commit) ./gradlew ktlintCheck # Auto-fix lint issues ./gradlew ktlintFormat # Run all checks ./gradlew check
This is a Clean Architecture Android app with MVVM + MVI (Orbit) pattern:
koin/ - Main student app (in.koreatech.koin) business/ - Business app (in.koreatech.business) domain/ - Repository interfaces, use cases, business models (pure Kotlin) data/ - Repository implementations, API services, DTOs core/ - Shared utilities (designsystem, network, analytics, navigation, notification) feature/ - Feature modules (timetable, bus, store, chat, club, dining, lostandfound, banner) build-logic/ - Custom Gradle convention plugins
MUST respect these boundaries:
domain/): Pure Kotlin. Repository interfaces, use cases, business models. Uses Result<T> or Flow for new code. Legacy code uses Pair<T?, ErrorHandler?> pattern.data/): Repository implementations, Retrofit API services, data sources. Handles network/local data.koin/, business/, feature/): ViewModels with Orbit MVI, Jetpack Compose UI, legacy XML views.Critical Rule: ViewModels MUST call UseCases. ViewModels NEVER call Repositories directly.
CRITICAL: The codebase uses different UI technologies across modules:
| Module | Primary UI Technology | Pattern |
|---|---|---|
| Legacy XML + ViewBinding | Activities extend , use delegate, Compose embedded via |
| Jetpack Compose | Compose-first with Orbit MVI |
| Hybrid (XML + Compose) | Article/search/keyword screens use XML Fragments with Navigation Component; Lost & Found uses pure Compose with Orbit MVI |
(others) | Jetpack Compose | Pure Compose screens with + pattern |
koin/ Module Reality:
@Composable (embedded widgets, NOT full screens)KoinNavigationDrawerActivity as base class with MenuState enumfeature/article Module Reality:
When to use which:
ComposeView.setContent {} within XML layoutsfeature/ module with ComposeEach module has its own detailed AGENTS.md file with module-specific patterns and rules:
ALWAYS refer to module-specific AGENTS.md for detailed implementation patterns when working on a specific module.
MUST use backtick-escaped
in package and group imports in this order:
package `in`.koreatech.koin.feature.user.ui.signin import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import `in`.koreatech.koin.core.analytics.AnalyticsConstant import `in`.koreatech.koin.domain.usecase.user.UserLoginUseCase import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import org.orbitmvi.orbit.ContainerHost
Import grouping order:
in)MUST follow these patterns:
PascalCase + ViewModel suffix (e.g., SignInViewModel, StoreDetailViewModel)PascalCase + Repository suffix (e.g., UserRepository)Impl suffix (e.g., UserRepositoryImpl)PascalCase + UseCase suffix (e.g., UserLoginUseCase, GetStoresUseCase)camelCase for all functions (e.g., setLoginId(), fetchStores())camelCase for all variables_isLoading)isLoading)SCREAMING_SNAKE_CASE for top-level/companion constants (e.g., STORE_ID, MAX_RETRY_COUNT)MUST use explicit types for public APIs. Type inference is preferred for private/local variables.
Explicit types required:
// Public function signatures suspend fun getToken(loginId: String, hashedPassword: String): AuthToken // Public state flows val sessionId: StateFlow<String> = _sessionId // Container declaration override val container: Container<SignInState, SignInSideEffect> = container(SignInState())
Inference preferred:
// Private state flows private val _sessionId = MutableStateFlow("") // Local variables val currentUser = userRepository.getCurrentUser()
MUST use this pattern for ViewModels:
@HiltViewModel class SignInViewModel @Inject constructor( private val userLoginUseCase: UserLoginUseCase ) : ViewModel(), ContainerHost<SignInState, SignInSideEffect> { override val container = container<SignInState, SignInSideEffect>(SignInState()) // Synchronous state updates fun setLoginId(loginId: String) = blockingIntent { reduce { state.copy(loginId = loginId) } } // Asynchronous operations with LEGACY Pair<T?, ErrorHandler?> pattern // Uses custom onSuccess/onFailure extensions from domain.util.ErrorHandlerUtil fun signIn() = intent { userLoginUseCase(state.loginId, state.password) .onSuccess { postSideEffect(SignInSideEffect.SignInSuccess) } .onFailure { // 'it' is ErrorHandler, not Exception reduce { state.copy(loginError = SignInState.LoginError(true, it.message)) } } } // Asynchronous operations with MODERN Result<T> pattern // Uses Kotlin stdlib onSuccess/onFailure fun likeClub(clubId: Int) = intent { setClubLikeUseCase(clubId) .onSuccess { postSideEffect(ClubSideEffect.LikeSuccess) } .onFailure { exception -> // 'exception' is Throwable reduce { state.copy(error = exception.message) } } } }
Rules:
@HiltViewModelContainerHost<State, SideEffect>intent { } for asynchronous operationsblockingIntent { } for synchronous state updatesreduce { } to update state immutablypostSideEffect() for one-time events (navigation, toasts, etc.)MUST use
Result<T> for error handling in new code:
override suspend fun addCartItem(cartAdd: CartAdd): Result<Unit> { return runCatching { storeRemoteDataSource.addCartItem(cartAdd.toCartAddRequest()) }.onFailure { e -> return Result.failure( when (e) { is HttpException -> { when (e.code()) { 400 -> when (e.getErrorResponse().code) { "DIFFERENT_SHOP_ITEM_IN_CART" -> KoinStoreException.DifferentShopItemInCartException() "MENU_SOLD_OUT" -> KoinStoreException.MenuSoldOutException() else -> KoinStoreException.BadRequestException() } 401 -> KoinStoreException.UnauthorizedException() else -> e.getErrorResponse().toKoinUnknownErrorException() } } else -> e } ) } }
Rules:
Result<T> as return type for repository functionsrunCatching { } to wrap API callsKoinStoreException, KoinUserException)else branchMUST use operator invoke pattern for use cases.
The codebase uses two different error handling patterns:
Pattern 1: Legacy
(user/auth domain):Pair<T?, ErrorHandler?>
class UserLoginUseCase @Inject constructor( private val userRepository: UserRepository, private val tokenRepository: TokenRepository, private val userErrorHandler: UserErrorHandler ) { suspend operator fun invoke( email: String, password: String ): Pair<Unit?, ErrorHandler?> { return try { val authToken = userRepository.getToken(email, password.toSHA256()) tokenRepository.saveAccessToken(authToken.token) tokenRepository.saveRefreshToken(authToken.refreshToken) // ... user type handling Unit to null // Success: value to null error } catch (throwable: Throwable) { null to userErrorHandler.handleGetTokenError(throwable) // Failure: null to error } } }
Pattern 2: Modern Kotlin
(club, chat, timetable, store):Result<T>
class SetClubLikeUseCase @Inject constructor( private val clubRepository: ClubRepository ) { suspend operator fun invoke(clubId: Int): Result<Unit> = clubRepository.setClubLike(clubId) }
When to use which:
| Pattern | Use When | Examples |
|---|---|---|
| Maintaining legacy user/auth code | , |
| New features (preferred) | , |
| Observable/streaming data | , |
For NEW code: Always use
Result<T> or Flow<T>. Do NOT introduce new Pair<T?, ErrorHandler?> patterns.
Rules:
@Inject constructor for dependency injectionoperator fun invoke(...) as the main functionsuspend if performing async operationsMUST follow the two-function pattern:
// Outer function: ViewModel connection @Composable fun SignInScreen( modifier: Modifier = Modifier, nextRoute: () -> Unit = {}, viewModel: SignInViewModel = hiltViewModel() ) { val uiState by viewModel.collectAsState() val sessionId by viewModel.sessionId.collectAsState() viewModel.collectSideEffect { sideEffect -> when (sideEffect) { is SignInSideEffect.SignInSuccess -> nextRoute() } } LaunchedEffect(Unit) { viewModel.initialize() } SignInScreenImpl( loginId = uiState.loginId, password = uiState.password, isError = uiState.loginError.isError, setLoginId = viewModel::setLoginId, signIn = viewModel::signIn ) } // Inner function: Pure UI (Preview-compatible) @Composable fun SignInScreenImpl( loginId: String, password: String, isError: Boolean, modifier: Modifier = Modifier, setLoginId: (String) -> Unit = {}, signIn: () -> Unit = {} ) { // Pure UI implementation } @Preview(showSystemUi = true) @Composable private fun SignInScreenPreview() { SignInScreenImpl(loginId = "", password = "", isError = false) }
Rules:
*Screen (ViewModel-connected) and *ScreenImpl (pure UI)hiltViewModel() in outer functioncollectAsState() in outer functioncollectSideEffect in outer function*Impl function*Impl for Preview compatibility@Preview to *Impl function (private)*Impl functionMUST use Hilt for dependency injection:
ViewModels:
@HiltViewModel class MyViewModel @Inject constructor( private val useCase: MyUseCase ) : ViewModel(), ContainerHost<State, SideEffect>
Repositories & Use Cases:
class MyRepositoryImpl @Inject constructor( private val remoteDataSource: MyRemoteDataSource, private val localDataSource: MyLocalDataSource ) : MyRepository
Modules (only when needed):
@Module @InstallIn(SingletonComponent::class) object MyModule { @Provides @Singleton fun provideMyRepository( remoteDataSource: MyRemoteDataSource ): MyRepository = MyRepositoryImpl(remoteDataSource) }
Rules:
@HiltViewModel for ViewModels@Inject constructor for repositories and use cases@Singleton scope for repositoriesMUST use private/public StateFlow pattern:
private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow<Boolean> = _isLoading private val _user = MutableStateFlow<User?>(null) val user: StateFlow<User?> = _user
Rules:
MutableStateFlow for private backing property with underscore prefixStateFlow without underscoreMutableStateFlow publiclyThese rules are non-negotiable:
Layer Boundaries: ViewModels MUST call UseCases. ViewModels NEVER call Repositories directly.
New Features: MUST use Jetpack Compose for all new UI features. Legacy XML views are only for maintenance.
Code Style: MUST run
./gradlew ktlintFormat before every commit. CI will reject PRs that fail ktlint.
Kotlin Conventions: MUST follow Kotlin official naming conventions.
Dependency Injection: NEVER skip Hilt. ALWAYS use
@Inject or @Provides.
Use Case Pattern: ALWAYS use
operator fun invoke() for use cases. NEVER name it execute() or similar.
Error Handling: MUST use
Result<T> for new repository functions. MUST map exceptions to domain exceptions.
State Updates: ALWAYS use
reduce { state.copy(...) } in Orbit. NEVER mutate state directly.
Compose Pattern: ALWAYS follow the two-function pattern (
*Screen + *ScreenImpl). NEVER use ViewModel in *Impl.
Imports: MUST use backtick-escaped package names for
in (Kotlin reserved keyword).
The following ktlint rules are disabled in
.editorconfig:
ktlint_standard_package-name - Disabled (allows backtick-escaped in package)ktlint_standard_property-naming - Disabledktlint_standard_if-else-wrapping - Disabledktlint_standard_discouraged-comment-location - Disabledktlint_standard_max-line-length - Disabledktlint_function_naming_ignore_when_annotated_with - Composable functions exemptCode style:
android_studio
MUST ensure ktlint passes before pushing to any branch.
--- title: KOIN Git Flow --- %%{init: { 'logLevel': 'debug', 'theme': 'base', 'gitGraph': {'showBranches': true, 'mainBranchName': 'production'}} }%% gitGraph commit tag: "v1.0.0" branch hotfix/A checkout production branch develop checkout develop commit branch feature/A checkout feature/A checkout production checkout hotfix/A commit checkout develop checkout feature/A commit checkout production merge hotfix/A tag: "v1.0.1" checkout feature/A commit checkout develop branch feature/B commit checkout develop merge hotfix/A checkout feature/B commit checkout feature/A commit checkout develop merge feature/A branch release/v1.1.0 checkout develop merge feature/B branch release/v1.1.0B checkout release/v1.1.0 commit commit checkout release/v1.1.0B commit commit checkout production merge release/v1.1.0 tag: "v1.1.0" merge release/v1.1.0B tag: "v1.1.0B" checkout release/v1.1.0 checkout develop merge release/v1.1.0 merge release/v1.1.0B
| Branch | Purpose | Merge Target |
|---|---|---|
| Play Store release branch. Contains production-ready code. | N/A (target branch) |
| Release preparation branch. Code fixes, error corrections, and version updates happen here. | , |
| Development integration branch. All feature branches merge here. | N/A (integration branch) |
| Feature development branch. Created per feature unit. | |
| Urgent fix branch. Created when issues arise in release/production. | , |
MUST follow these naming patterns:
| Branch Type | Pattern | Example |
|---|---|---|
| Feature | | |
| Bug Fix | | |
| Hotfix | | |
| Release | | |
Feature Development:
developdevelopRelease Process:
develop when ready for releaseproduction AND develop after releaseHotfix Process:
production for critical fixesproduction AND developCommit messages MUST follow this format:
<type>: Subject <body>
| Type | Description |
|---|---|
| New feature development |
| Adding code or files that are not new features |
| Bug fixes |
| Documentation changes (README, AGENTS.md, etc.) |
| Code refactoring without behavior change |
| Adding or modifying test code |
| Deleting unnecessary code or files |
| Minor changes and maintenance tasks |
Type:
Subject:
Body:
feat: Add spring animation for store collapsing toolbar refactor: Extract common toolbar animation logic fix: Resolve null pointer exception in user login docs: Update AGENTS.md with correct bus module patterns This commit fixes fabricated code examples that did not match the actual implementation patterns in the codebase.
local.properties MUST contain:
Last Updated: 2026-01-06 For: AI Coding Agents (Claude, Cursor, GitHub Copilot, etc.) Maintainers: BCSD Android Track