12 KiB
Architecture Documentation
Overview
My ActivityPub follows the MVVM (Model-View-ViewModel) architecture pattern combined with Repository pattern for clean separation of concerns and testability.
Architecture Layers
┌─────────────────────────────────────────────────┐
│ UI Layer (View) │
│ Jetpack Compose Composables │
└──────────────────┬──────────────────────────────┘
│ observes StateFlow
↓
┌─────────────────────────────────────────────────┐
│ ViewModel Layer │
│ Business Logic & State Management │
└──────────────────┬──────────────────────────────┘
│ calls methods
↓
┌─────────────────────────────────────────────────┐
│ Repository Layer │
│ Data Operations Abstraction │
└──────────────────┬──────────────────────────────┘
│ makes API calls
↓
┌─────────────────────────────────────────────────┐
│ Data Source Layer │
│ API Service (Retrofit) │
└──────────────────┬──────────────────────────────┘
│ HTTP requests
↓
┌─────────────────────────────────────────────────┐
│ Remote Server │
│ Mastodon/ActivityPub API │
└─────────────────────────────────────────────────┘
Layer Details
1. UI Layer (View)
Location: ui/components/, MainActivity.kt
Responsibilities:
- Display data to the user
- Capture user interactions
- Observe ViewModel state
- Render UI using Jetpack Compose
Key Components:
MainActivity.kt
The entry point of the application. Sets up the Compose UI, dependency injection (manual), and hosts the main composable.
@Composable
fun MyActivityPubApp() {
// Setup dependencies
val repository = MastodonRepository(apiService)
val viewModel: TimelineViewModel = viewModel { TimelineViewModel(repository) }
// Observe state
val uiState by viewModel.uiState.collectAsState()
// Render UI based on state
when (uiState) {
is TimelineUiState.Loading -> LoadingUI()
is TimelineUiState.Success -> TimelineUI(statuses)
is TimelineUiState.Error -> ErrorUI(message)
}
}
StatusCard.kt
Reusable composable component for displaying individual status posts.
Features:
- User avatar and information
- HTML content rendering
- Media attachment display
- Interaction buttons (reply, boost, favorite)
- Timestamp formatting
2. ViewModel Layer
Location: ui/viewmodel/
Responsibilities:
- Manage UI state
- Handle business logic
- Coordinate data operations
- Expose data streams to UI
- Survive configuration changes
Key Components:
TimelineViewModel.kt
Manages the timeline screen state and operations.
State Management:
sealed class TimelineUiState {
object Loading : TimelineUiState()
data class Success(val statuses: List<Status>) : TimelineUiState()
data class Error(val message: String) : TimelineUiState()
}
Flow:
- UI subscribes to
uiStateStateFlow - ViewModel fetches data from repository
- ViewModel updates state based on result
- UI recomposes with new state
Benefits:
- Survives configuration changes (screen rotation)
- Separates UI logic from business logic
- Makes testing easier
- Provides lifecycle awareness
3. Repository Layer
Location: data/repository/
Responsibilities:
- Abstract data sources
- Provide clean API to ViewModel
- Handle data operations
- Implement error handling
- Coordinate multiple data sources (if needed)
Key Components:
MastodonRepository.kt
Provides data operations for Mastodon API.
Methods:
getPublicTimeline(): Fetch public timelinegetInstanceInfo(): Get instance information
Pattern:
suspend fun getPublicTimeline(limit: Int, local: Boolean): Result<List<Status>> {
return withContext(Dispatchers.IO) {
try {
val response = apiService.getPublicTimeline(limit, local)
if (response.isSuccessful) {
Result.success(response.body() ?: emptyList())
} else {
Result.failure(Exception("Error: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
Benefits:
- Single source of truth
- Easy to test with mock data
- Can add caching layer
- Switches between local/remote data
4. Data Source Layer
Location: data/api/, data/models/
Responsibilities:
- Define API endpoints
- Make network requests
- Parse JSON responses
- Define data models
Key Components:
MastodonApiService.kt
Retrofit interface defining API endpoints.
interface MastodonApiService {
@GET("api/v1/timelines/public")
suspend fun getPublicTimeline(
@Query("limit") limit: Int = 20,
@Query("local") local: Boolean = false
): Response<List<Status>>
}
Data Models
Status.kt: Represents a post/toot Account.kt: Represents a user account MediaAttachment.kt: Represents media files Instance.kt: Represents server instance info
Data Flow
Loading Timeline Example
1. User opens app
↓
2. MainActivity creates ViewModel
↓
3. ViewModel initializes and calls loadTimeline()
↓
4. ViewModel sets state to Loading
↓
5. UI shows loading indicator
↓
6. ViewModel calls repository.getPublicTimeline()
↓
7. Repository calls apiService.getPublicTimeline()
↓
8. Retrofit makes HTTP request to Mastodon API
↓
9. API returns JSON response
↓
10. Gson parses JSON to List<Status>
↓
11. Repository returns Result.success(statuses)
↓
12. ViewModel updates state to Success(statuses)
↓
13. UI recomposes with status list
↓
14. StatusCard components render each status
Error Handling Flow
Network Error occurs
↓
Repository catches exception
↓
Returns Result.failure(exception)
↓
ViewModel updates state to Error(message)
↓
UI shows error message and retry button
↓
User clicks retry
↓
Flow starts again from step 3
State Management
UI State Pattern
The app uses unidirectional data flow with sealed classes for state:
sealed class TimelineUiState {
object Loading : TimelineUiState()
data class Success(val statuses: List<Status>) : TimelineUiState()
data class Error(val message: String) : TimelineUiState()
}
Benefits:
- Type-safe state representation
- Exhaustive when expressions
- Clear state transitions
- Easy to add new states
StateFlow vs LiveData
The app uses StateFlow instead of LiveData because:
- Better Kotlin coroutines integration
- More powerful operators
- Lifecycle-aware collection in Compose
- Can be used outside Android
Dependency Management
Current Implementation
The app uses manual dependency injection in MainActivity:
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://mastodon.social/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
val apiService = retrofit.create(MastodonApiService::class.java)
val repository = MastodonRepository(apiService)
val viewModel = TimelineViewModel(repository)
Future Improvements
For larger apps, consider:
- Hilt: Dependency injection framework
- Koin: Lightweight DI for Kotlin
- Manual DI with modules: Create separate DI container classes
Threading Model
Coroutines and Dispatchers
The app uses Kotlin coroutines for async operations:
Dispatchers.Main: UI operations
viewModelScope.launch { // Main by default
_uiState.value = TimelineUiState.Loading
}
Dispatchers.IO: Network and disk I/O
withContext(Dispatchers.IO) {
apiService.getPublicTimeline()
}
Dispatcher.Default: CPU-intensive work
withContext(Dispatchers.Default) {
// Heavy computation
}
Error Handling Strategy
Result Pattern
The repository uses Kotlin's Result type:
suspend fun getPublicTimeline(): Result<List<Status>> {
return try {
Result.success(data)
} catch (e: Exception) {
Result.failure(e)
}
}
Consumption in ViewModel:
repository.getPublicTimeline().fold(
onSuccess = { data -> /* handle success */ },
onFailure = { error -> /* handle error */ }
)
Error Types
- Network Errors: No internet, timeout
- HTTP Errors: 4xx, 5xx status codes
- Parse Errors: Invalid JSON
- Unknown Errors: Unexpected exceptions
Testing Strategy
Unit Testing
Repository Layer:
@Test
fun `getPublicTimeline returns success with valid data`() = runTest {
val mockApi = mock<MastodonApiService>()
val repository = MastodonRepository(mockApi)
// Test implementation
}
ViewModel Layer:
@Test
fun `loadTimeline updates state to Success`() = runTest {
val mockRepository = mock<MastodonRepository>()
val viewModel = TimelineViewModel(mockRepository)
// Test implementation
}
Integration Testing
Test complete flows from ViewModel to Repository to API.
UI Testing
Use Compose Testing to test UI components:
@Test
fun statusCard_displays_content() {
composeTestRule.setContent {
StatusCard(status = testStatus)
}
composeTestRule
.onNodeWithText("Test content")
.assertIsDisplayed()
}
Performance Considerations
Image Loading
Coil library handles:
- Async loading
- Caching (memory + disk)
- Placeholder/error images
- Automatic lifecycle management
List Performance
LazyColumn provides:
- Lazy loading (only visible items rendered)
- Recycling (item reuse)
- Efficient scrolling
Memory Management
- ViewModels survive configuration changes
- Images are cached efficiently
- Lists use lazy loading
Security Considerations
- HTTPS Only: All API calls use HTTPS
- No Cleartext Traffic: Disabled in manifest
- Input Validation: Validate all user input
- Rate Limiting: Respect API rate limits
- Error Messages: Don't expose sensitive info in errors
Future Enhancements
Recommended Additions
- Authentication: OAuth 2.0 implementation
- Database: Room for offline caching
- Pagination: Implement infinite scroll
- Deep Linking: Handle Mastodon URLs
- Search: Add search functionality
- Notifications: Push notifications support
- Multiple Accounts: Account switching
- Dark Theme: Complete theme support
- Accessibility: Enhanced accessibility features
- Testing: Comprehensive test coverage
Architecture Evolution
As the app grows:
- Move to multi-module architecture
- Implement Clean Architecture layers
- Add use cases/interactors
- Separate feature modules
- Add shared UI components module