Files
MyActivityPub/docs/ARCHITECTURE.md
2026-01-24 17:45:29 +01:00

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:

  1. UI subscribes to uiState StateFlow
  2. ViewModel fetches data from repository
  3. ViewModel updates state based on result
  4. 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 timeline
  • getInstanceInfo(): 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

  1. Network Errors: No internet, timeout
  2. HTTP Errors: 4xx, 5xx status codes
  3. Parse Errors: Invalid JSON
  4. 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

  1. HTTPS Only: All API calls use HTTPS
  2. No Cleartext Traffic: Disabled in manifest
  3. Input Validation: Validate all user input
  4. Rate Limiting: Respect API rate limits
  5. Error Messages: Don't expose sensitive info in errors

Future Enhancements

  1. Authentication: OAuth 2.0 implementation
  2. Database: Room for offline caching
  3. Pagination: Implement infinite scroll
  4. Deep Linking: Handle Mastodon URLs
  5. Search: Add search functionality
  6. Notifications: Push notifications support
  7. Multiple Accounts: Account switching
  8. Dark Theme: Complete theme support
  9. Accessibility: Enhanced accessibility features
  10. 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

References