Architecture

Modules Gradle, couches, data flow et stratégie de cache.


Couches

L’application suit une architecture en 3 couches strictes. Chaque couche ne peut dépendre que de la couche en dessous. Les frontières sont enforces par les modules Gradle — pas de convention implicite.

graph TB
    subgraph "Presentation"
        direction LR
        S["Screens (Compose)"]
        VM["ViewModels (MVI)"]
        S --> VM
    end
    subgraph "Domaine"
        RI["Repository interfaces"]
        M["Modèles domaine"]
    end
    subgraph "Données"
        direction LR
        IMPL["Repository implémentations"]
        NET["HfrClient (OkHttp)"]
        PARSE["HfrParser (Jsoup)"]
        DB["Room Database"]
        IMPL --> NET
        IMPL --> PARSE
        IMPL --> DB
    end
    VM --> RI
    RI --> M
    IMPL -.->|implémente| RI
  • Presentation (:feature:*) : Compose UI + ViewModels MVI. Ne connait que les interfaces de repositories et les modèles domaine.
  • Domaine (:core:domain + :core:model) : Interfaces de repositories + modèles purs. Aucune dépendance framework. Frontière de compilation.
  • Données (:core:data + :core:network + :core:parser + :core:database) : Implémentations concrètes. Les features ne dépendent jamais de cette couche directement — Hilt injecte les implémentations.

Modules Gradle

graph TB
    APP[":app"] --> FF[":feature:forum"]
    APP --> FT[":feature:topic"]
    APP --> FE[":feature:editor"]
    APP --> FM[":feature:messages"]
    APP --> FA[":feature:auth"]
    APP --> FS[":feature:settings"]
    APP --> FSR[":feature:search"]
    APP --> CDATA[":core:data"]

    FF --> CDOM[":core:domain"]
    FF --> CU[":core:ui"]

    FT --> CDOM
    FT --> CU
    FT --> CEXT[":core:extension"]

    FE --> CDOM
    FE --> CU
    FE --> CEXT

    FM --> CDOM
    FM --> CU

    FA --> CDOM
    FA --> CU

    FS --> CDOM
    FS --> CU

    FSR --> CDOM
    FSR --> CU

    CDOM --> CM[":core:model"]
    CEXT --> CM
    CU --> CM

    CDATA --> CDOM
    CDATA --> CN[":core:network"]
    CDATA --> CP[":core:parser"]
    CDATA --> CD[":core:database"]

    CN --> CM
    CP --> CM
    CD --> CM

    style APP fill:#e74c3c,color:#fff
    style CM fill:#f39c12,color:#fff
    style CDOM fill:#e67e22,color:#fff
    style CDATA fill:#16a085,color:#fff
    style CN fill:#2ecc71,color:#fff
    style CP fill:#27ae60,color:#fff
    style CD fill:#3498db,color:#fff
    style CU fill:#9b59b6,color:#fff
    style CEXT fill:#8e44ad,color:#fff

Modules core

Module Responsabilité Dépend de
:core:model Modèles domaine purs (Topic, Post, Category, Flag, MP). Aucune dépendance Android. rien
:core:domain Interfaces de repositories (TopicRepository, FlagRepository, AuthRepository…) et règles métier partagées. Aucune dépendance framework. :core:model
:core:data Implémentations des repositories. Orchestre réseau, parser et cache. Fournit les bindings Hilt. :core:domain, :core:network, :core:parser, :core:database
:core:network HfrClient : requêtes HTTP, cookies, session, login. Encapsule OkHttp. :core:model
:core:parser HfrParser : transforme le HTML HFR en modèles domaine via Jsoup. :core:model
:core:database Room DB, DAOs, entities, mappers entity↔model. Cache locale + cache MPStorage. :core:model
:core:ui Thème Material 3, composants partagés, PostRenderer (BBCode → Compose). :core:model
:core:extension Interfaces d’extension : PostDecorator, TopicToolbarContributor, EditorToolbarContributor. :core:model

Modules feature (base)

Les features ne dépendent que de :core:domain (interfaces) et :core:ui (composants partagés). Elles ne connaissent pas la couche données — Hilt injecte les implémentations depuis :core:data.

Module Écrans Dépend de
:feature:forum Catégories, sous-catégories, liste de topics :core:domain, :core:ui
:feature:topic Lecture de topic, pagination :core:domain, :core:ui, :core:extension
:feature:editor Reply, edit, edit FP, preview BBCode, création topic :core:domain, :core:ui, :core:extension
:feature:messages MPs classiques, MultiMPs, création MP/MultiMP :core:domain, :core:ui
:feature:auth Login HFR :core:domain, :core:ui
:feature:search Recherche dans les topics et posts, filtres :core:domain, :core:ui
:feature:settings Préférences, thème, gestion cache :core:domain, :core:ui

Modules feature (extensions communautaires — Phase 4)

Les 8 modules extension arrivent en Phase 4 uniquement. En Phases 0 à 3, le projet compte 15 modules (8 core + 7 features base). Les extensions sont des modules Gradle isolés qui s’enregistrent via Hilt @IntoSet — ajouter une extension ne demande aucune modification du code existant.

Module Fonction Dépend de
:feature:bookmarks Sauvegarder des posts :core:extension, :core:model, :core:database
:feature:blacklist Masquer des utilisateurs :core:extension, :core:model, :core:database
:feature:qualitay Signaler un post remarquable :core:extension, :core:model, :core:network
:feature:redflag Alertes intelligentes (via CF Worker) :core:extension, :core:model, :core:network
:feature:colortag Colorer et annoter les pseudos :core:extension, :core:model, :core:database
:feature:imagehost Upload et bibliothèque d’images :core:model, :core:network, :core:ui
:feature:gifpicker Recherche et insertion de GIFs :core:model, :core:network, :core:ui
:feature:stats Statistiques utilisateur :core:model, :core:network

Module app

:app est le point d’entrée. Il :

  • Configure Hilt (DI) — inclut :core:data pour le wiring des implémentations
  • Définit le NavGraph (navigation globale)
  • Contient MainActivity
  • Dépend de tous les modules feature (base + extensions)

Séparation des responsabilités

:core:domain — interfaces

Les interfaces de repositories vivent dans le module domaine. Aucune dépendance framework.

// Dans :core:domain — le contrat
interface TopicRepository {
    suspend fun getTopic(cat: Int, post: Int, page: Int): Result<Topic>
    suspend fun prefetchNextPage(cat: Int, post: Int, page: Int)
}

interface FlagRepository {
    suspend fun getFlags(): Result<List<FlaggedTopic>>
    suspend fun removeFlag(topic: FlaggedTopic): Result<Unit>
}

interface AuthRepository {
    suspend fun login(username: String, password: String): Result<Unit>
    suspend fun isLoggedIn(): Boolean
}

:core:network — HfrClient

Le client HTTP ne parse rien. Il retourne du HTML brut ou des confirmations d’action.

class HfrClient @Inject constructor(
    private val okHttpClient: OkHttpClient,
) {
    suspend fun fetchTopicPage(cat: Int, post: Int, page: Int): String
    suspend fun fetchFlags(): String
    suspend fun postReply(cat: Int, post: Int, content: String): Result<Unit>
    suspend fun editPost(cat: Int, post: Int, numreponse: Int, content: String): Result<Unit>
    suspend fun login(username: String, password: String): Result<Unit>
    // ...
}

:core:parser — HfrParser

Le parser transforme le HTML en modèles domaine. Isolé de toute logique réseau.

class HfrParser @Inject constructor() {
    fun parseTopicPage(html: String): Topic
    fun parseFlags(html: String): List<FlaggedTopic>
    fun parseCategories(html: String): List<Category>
    fun parseEditPage(html: String): EditInfo
    fun parseMessageList(html: String): List<PrivateMessage>
    // ...
}

:core:data — implémentations

Les implémentations de repositories orchestrent réseau, parser et cache. Elles vivent dans :core:data, jamais dans les features.

// Dans :core:data — l'implémentation
class TopicRepositoryImpl @Inject constructor(
    private val client: HfrClient,
    private val parser: HfrParser,
    private val topicDao: TopicDao,
) : TopicRepository {

    override suspend fun getTopic(cat: Int, post: Int, page: Int): Result<Topic> {
        // 1. Vérifier le cache
        topicDao.getCached(cat, post, page)?.let { return Result.success(it) }

        // 2. Fetch + parse
        return runCatching {
            val html = client.fetchTopicPage(cat, post, page)
            val topic = parser.parseTopicPage(html)

            // 3. Mettre en cache
            topicDao.insert(topic.toEntity())

            topic
        }
    }
}

Le binding Hilt connecte l’interface à l’implémentation :

// Dans :core:data
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindTopicRepository(impl: TopicRepositoryImpl): TopicRepository

    @Binds
    abstract fun bindFlagRepository(impl: FlagRepositoryImpl): FlagRepository
}

Les ViewModels dans les features ne connaissent que l’interface :

// Dans :feature:topic — ne dépend que de :core:domain
@HiltViewModel
class TopicViewModel @Inject constructor(
    private val topicRepository: TopicRepository,  // interface, pas impl
) : ViewModel() { ... }

Stratégie de cache

Donnée Stratégie Durée
Topics lus Cache Room, invalidation au refresh Jusqu’au refresh
Drapeaux Cache Room, refresh au lancement + pull-to-refresh 5 min TTL
Catégories Cache Room, rarement change 24h TTL
Smileys Cache Coil, ne changent jamais Infini
Avatars Cache Coil, ETag 1h TTL
MultiMP flags Room, jamais expire (donnée locale) Permanent
Préférences DataStore Permanent

Prefetch intelligent

Pour donner l’impression que le forum est local :

Utilisateur lit la page 3 d'un topic
  → Prefetch page 4 en arrière-plan
  → Quand il scroll vers le bas, la page 4 est déjà prête

Utilisateur ouvre ses drapeaux
  → Prefetch les 3 premiers topics (ceux qu'il ouvre le plus souvent)

Le prefetch respecte les conditions réseau : désactivé en mode économie de données ou réseau lent.


Gestion de session

HFR utilise des cookies de session. Le flow d’authentification :

sequenceDiagram
    participant App
    participant OkHttp
    participant HFR

    App->>OkHttp: login(user, pass)
    OkHttp->>HFR: POST /login_validation.php
    HFR-->>OkHttp: Set-Cookie md_user, md_pass
    OkHttp->>OkHttp: CookieJar stocke les cookies

    Note over App,HFR: Toutes les requêtes suivantes incluent les cookies

    App->>OkHttp: fetchFlags()
    OkHttp->>HFR: GET /forum1f.php + cookies
    HFR-->>OkHttp: HTML drapeaux
    OkHttp-->>App: HTML brut

Les cookies sont persistés via un PersistentCookieJar (Room ou fichier) pour éviter de se re-logguer à chaque lancement.

Stockage sécurisé des credentials

Les cookies et credentials HFR sont chiffrés au repos via EncryptedSharedPreferences (AndroidX Security) :

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val securePrefs = EncryptedSharedPreferences.create(
    context, "hfr_credentials", masterKey,
    PrefKeyEncryptionScheme.AES256_SIV,
    PrefValueEncryptionScheme.AES256_GCM,
)

Le PersistentCookieJar sérialise les cookies dans ces préférences chiffrées. Le mot de passe HFR (pour le re-login automatique en cas d’expiration de session) y est également stocké.

L’utilisation de la Biometric API pour protéger l’accès à l’app est envisagée pour une version ultérieure. Le stockage est conçu pour qu’une clé biométrique puisse être ajoutée sans migration.


Gestion des erreurs

Session expirée

Un Interceptor OkHttp détecte la redirection vers la page de login (HTTP 302 ou absence du cookie md_user dans la réponse). Il tente un re-login transparent avec les credentials stockés. Si le re-login échoue, un événement SessionExpired est émis et le NavGraph redirige vers l’écran de login.

HFR indisponible

Le repository retourne Result.failure → le ViewModel affiche les données du cache Room + une bannière “HFR indisponible, données en cache”. Retry automatique avec backoff exponentiel (2s, 4s, 8s, max 60s).

Rate limiting

Interceptor OkHttp avec détection des réponses HTTP 429 et des patterns de blocage HFR. File d’attente côté client avec rate limit (max 2 req/s vers HFR). Backoff automatique sur 429.

Breakage du parser

HfrParser wrappe chaque méthode dans runCatching. Sur échec, le HTML brut est loggé en mode debug pour diagnostic. Un smoke test CI hebdomadaire vérifie que les sélecteurs CSS critiques (HfrSelectors) matchent toujours sur une vraie page HFR publique.


Haut de page

Redface 2 — Specs v0.3.1 — Un projet communautaire pour Hardware.fr

This site uses Just the Docs, a documentation theme for Jekyll.