YouTubeWhitelist - Developer Onboarding Guide
YouTubeWhitelist - Developer Onboarding Guide
Welcome to the YouTubeWhitelist project! This guide will help you get up and running quickly as a contributor. It covers everything from environment setup to architecture patterns, testing conventions, and common pitfalls.
Table of Contents
- Project Overview
- Prerequisites
- Environment Setup
- Project Structure
- Architecture
- Module Deep Dive
- Navigation System
- Database Schema
- Dependency Injection
- Network Layer (YouTube API)
- Authentication & Security
- State Management Patterns
- Testing Guide
- Build & Release
- Code Style & Conventions
- Common Pitfalls & Lessons Learned
- Existing Documentation
- Contribution Workflow
1. Project Overview
YouTubeWhitelist is a free, open-source (GPLv3) Android app that lets parents create safe YouTube environments for their kids. Parents whitelist specific channels, videos, and playlists — kids only see approved content.
Key characteristics:
- 100% client-side (no backend server)
- No ads, no tracking
- F-Droid compatible (no Google Play Services SDK dependency)
- Two user modes: Parent Mode (full YouTube browsing + management) and Kid Mode (whitelisted content only)
| Metric | Value |
|---|---|
| Language | Kotlin |
| UI Framework | Jetpack Compose + Material Design 3 |
| Architecture | MVVM + Clean Architecture |
| Min SDK | 26 (Android 8.0) |
| Target SDK | 35 (Android 15) |
| Modules | 10 (1 app + 3 feature + 6 core) |
| Screens | 20 |
| Tests | 401+ |
| Release APK | 2.4 MB |
| License | GPLv3 |
2. Prerequisites
| Tool | Version | Notes |
|---|---|---|
| JDK | 17 | Required. Higher versions may cause Gradle issues |
| Android Studio | 2024.x+ | Or any IDE with Kotlin/Gradle support |
| Android SDK | API 35 | Install via SDK Manager |
| Git | Any recent | For version control |
| Google Cloud Console account | — | For YouTube API key and OAuth credentials |
macOS-Specific Setup
# Install JDK 17 via Homebrew
brew install openjdk@17
# Set JAVA_HOME (add to ~/.zshrc or ~/.bashrc)
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
# Install Android command-line tools (alternative to Android Studio)
brew install --cask android-commandlinetools
3. Environment Setup
3.1 Clone the Repository
git clone https://github.com/degipe/YouTubeWhitelist.git
cd YouTubeWhitelist
3.2 Create local.properties
This file is git-ignored and contains secrets. Create it in the project root:
# Android SDK location (auto-created by Android Studio)
sdk.dir=/path/to/your/android/sdk
# YouTube Data API v3 key (required for app functionality)
YOUTUBE_API_KEY=your_youtube_api_key_here
# Google OAuth 2.0 credentials (required for sign-in)
# Must be "Web application" type, NOT "Android" type
# Redirect URI: http://localhost/callback
GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret
# Release signing (optional, only needed for release builds)
RELEASE_KEYSTORE_PATH=release-keystore.jks
RELEASE_KEYSTORE_PASSWORD=your_keystore_password
RELEASE_KEY_ALIAS=release_key
RELEASE_KEY_PASSWORD=your_key_password
3.3 Google Cloud Console Setup
- Create a new project at Google Cloud Console
- Enable YouTube Data API v3
- Create an API Key (restrict to YouTube Data API v3 later)
- Configure OAuth consent screen (External, Testing mode)
- Create OAuth 2.0 Client ID:
- Type: Web application (NOT Android!)
- Authorized redirect URI:
http://localhost/callback
- Add test users to OAuth consent screen
For detailed instructions, see GOOGLE_SETUP.md.
3.4 Build and Run
# Build debug APK
./gradlew assembleDebug
# Run all unit tests
./gradlew test
# Run instrumentation tests (requires emulator/device)
./gradlew connectedAndroidTest
# Build release APK (requires signing config in local.properties)
./gradlew assembleRelease
# Build AAB for Play Store
./gradlew bundleRelease
3.5 Install on Device/Emulator
# Via adb
adb install app/build/outputs/apk/debug/app-debug.apk
# Or run directly from Android Studio
# Select device → Run → app
4. Project Structure
YouTubeWhitelist/
├── app/ # Main application module
│ ├── src/main/java/.../
│ │ ├── MainActivity.kt # Single Activity, hosts Compose NavHost
│ │ ├── YouTubeWhitelistApp.kt # Hilt Application class
│ │ ├── navigation/
│ │ │ ├── Route.kt # Type-safe navigation routes (sealed interface)
│ │ │ └── AppNavigation.kt # NavHost with all composable routes
│ │ ├── di/
│ │ │ └── ApiKeyModule.kt # Provides API keys from BuildConfig
│ │ └── ui/screen/ # App-level screens (splash, auth, PIN, profile)
│ └── src/test/ # Unit tests for app-level ViewModels
│
├── feature/ # Feature modules (UI layer)
│ ├── parent/ # Parent mode screens
│ │ └── src/main/java/.../ui/
│ │ ├── dashboard/ # ParentDashboardScreen + ViewModel
│ │ ├── whitelist/ # WhitelistManagerScreen + ViewModel
│ │ ├── browser/ # WebViewBrowserScreen + ViewModel
│ │ ├── profile/ # ProfileEditScreen + ViewModel
│ │ ├── stats/ # WatchStatsScreen + ViewModel
│ │ ├── exportimport/ # ExportImportScreen + ViewModel
│ │ └── about/ # AboutScreen (static, no ViewModel)
│ │
│ ├── kid/ # Kid mode screens
│ │ └── src/main/java/.../ui/
│ │ ├── home/ # KidHomeScreen + ViewModel
│ │ ├── channel/ # ChannelDetailScreen + ViewModel
│ │ ├── playlist/ # PlaylistDetailScreen + ViewModel
│ │ ├── player/ # VideoPlayerScreen + ViewModel
│ │ └── search/ # KidSearchScreen + ViewModel
│ │
│ └── sleep/ # Sleep mode
│ └── src/main/java/.../ui/ # SleepModeScreen + ViewModel
│
├── core/ # Core library modules (business logic + data)
│ ├── common/ # Shared utilities, theme, types
│ │ └── src/main/java/.../
│ │ ├── result/ # AppResult<T> sealed interface
│ │ ├── model/ # WhitelistItemType enum
│ │ └── ui/theme/ # Material 3 theme (colors, typography)
│ │
│ ├── data/ # Repository implementations, domain models
│ │ └── src/main/java/.../
│ │ ├── repository/ # 7 repository interfaces + implementations
│ │ ├── model/ # Domain models (WhitelistItem, KidProfile, etc.)
│ │ ├── sleep/ # SleepTimerManager
│ │ ├── timelimit/ # TimeLimitChecker
│ │ └── parser/ # YouTube URL parser
│ │
│ ├── database/ # Room database
│ │ └── src/main/java/.../
│ │ ├── entity/ # 5 Room entities
│ │ ├── dao/ # 5 DAO interfaces
│ │ ├── converter/ # Room type converters
│ │ └── di/ # DatabaseModule (Hilt)
│ │
│ ├── network/ # YouTube API client + free endpoints
│ │ └── src/main/java/.../
│ │ ├── api/ # YouTubeApiService (Retrofit interface)
│ │ ├── dto/ # API response DTOs
│ │ ├── oembed/ # OEmbedService + OEmbedResponse (free endpoint)
│ │ ├── rss/ # RssFeedParser + RssVideoEntry (free endpoint)
│ │ ├── invidious/ # InvidiousApiService, InvidiousInstanceManager, DTOs (fallback)
│ │ ├── interceptor/ # ApiKeyInterceptor
│ │ └── di/ # NetworkModule, @PlainOkHttp, @YouTubeApiOkHttp (Hilt)
│ │
│ ├── auth/ # Authentication & PIN management
│ │ └── src/main/java/.../
│ │ ├── repository/ # AuthRepository, PinRepository
│ │ ├── pin/ # PinHasher, BruteForceProtection
│ │ ├── token/ # EncryptedTokenManager
│ │ ├── signin/ # GoogleSignInManager, OAuthLoopbackServer
│ │ └── di/ # AuthModule (Hilt)
│ │
│ └── export/ # JSON export/import
│ └── src/main/java/.../
│ ├── model/ # Export DTOs
│ └── service/ # ExportService, ImportService
│
├── gradle/
│ ├── wrapper/ # Gradle wrapper (v8.11.1)
│ └── libs.versions.toml # Version catalog (all dependencies centralized)
│
├── docs/ # SDLC documentation
│ ├── BRD.md # Business Requirements Document
│ ├── FS.md # Functional Specification
│ ├── HLD.md # High-Level Design
│ └── LLD.md # Low-Level Design (948 lines, 18 Mermaid diagrams)
│
├── fastlane/metadata/android/ # F-Droid / Play Store metadata
│ ├── en-US/ # English descriptions + changelogs
│ └── hu-HU/ # Hungarian descriptions + changelogs
│
├── build.gradle.kts # Root build file (plugin declarations)
├── settings.gradle.kts # Module includes
├── CHANGELOG.md # Release notes (Keep a Changelog format)
├── README.md # Project overview
└── LICENSE # GPLv3
Package Convention
All packages follow this pattern:
io.github.degipe.youtubewhitelist.<module>.<layer>
Examples:
io.github.degipe.youtubewhitelist.core.data.repositoryio.github.degipe.youtubewhitelist.feature.kid.ui.homeio.github.degipe.youtubewhitelist.core.database.entity
5. Architecture
Layered Architecture
┌─────────────────────────────────────────────────────┐
│ Presentation Layer (feature modules) │
│ Compose Screens ← ViewModels ← UiState │
├─────────────────────────────────────────────────────┤
│ Domain Layer (core:data) │
│ Repository interfaces, Domain models │
├─────────────────────────────────────────────────────┤
│ Data Layer (core:database, core:network, core:auth)│
│ Room DAOs, Retrofit API, Token storage │
└─────────────────────────────────────────────────────┘
Module Dependency Graph
app ──────┬──→ feature:parent ──→ core:common
├──→ feature:kid ──→ core:data ──→ core:database
├──→ feature:sleep ──→ core:data ──→ core:network
├──→ core:common ──→ core:common
├──→ core:data
├──→ core:database
├──→ core:network
├──→ core:auth ──→ core:database
└──→ core:export ──→ core:database (DAOs directly)
──→ core:common
Data Flow (MVVM)
User Action → Composable → ViewModel → Repository → DAO/API
↑ ↓
UiState ← StateFlow ← Flow<Entity> from Room
Three-Layer Data Mapping
Network DTOs (core:network) → Domain Models (core:data) → Room Entities (core:database)
ChannelDto YouTubeMetadata.Channel WhitelistItemEntity
VideoDto WhitelistItem KidProfileEntity
PlaylistDto KidProfile WatchHistoryEntity
Error Handling: AppResult
All network/repository operations that can fail return AppResult<T>:
// Defined in core:common
sealed interface AppResult<out T> {
data class Success<T>(val data: T) : AppResult<T>
data class Error(val message: String, val exception: Throwable? = null) : AppResult<Nothing>
}
// Usage in repository
suspend fun addItemFromUrl(profileId: String, url: String): AppResult<WhitelistItem>
// Usage in ViewModel
when (val result = repository.addItemFromUrl(profileId, url)) {
is AppResult.Success -> handleSuccess(result.data)
is AppResult.Error -> showError(result.message)
}
6. Module Deep Dive
app Module
The application entry point. Contains:
MainActivity— Single Activity withsetContent { AppNavigation() }YouTubeWhitelistApp—@HiltAndroidAppapplication class- Navigation graph (
Route.kt,AppNavigation.kt) - App-level screens: Splash, SignIn, PIN Setup/Entry/Change, Profile Creation/Selector
ApiKeyModule— Provides API keys fromBuildConfigto Hilt
feature:parent
Parent mode UI. All screens follow the same pattern:
*Screen.kt— Composable function accepting ViewModel + navigation callbacks*ViewModel.kt—@HiltViewModelor@AssistedInjectfor runtime params
Screens: ParentDashboard, WhitelistManager, WebViewBrowser, ProfileEdit, WatchStats, ExportImport, About
feature:kid
Kid mode UI. The safest layer — all navigation is blocked, content is filtered.
Screens: KidHome, KidSearch, ChannelDetail, PlaylistDetail, VideoPlayer
Key security features in VideoPlayer:
shouldOverrideUrlLoadingreturnstrue(blocks ALL WebView navigation)- YouTube IFrame Player error codes 101/150 auto-skip (embed-disabled videos)
- Sleep timer/time limit overlays pause video and block interaction
feature:sleep
Sleep timer screen with dark theme. Three states: timer selection, running, expired.
core:common
Shared across all modules:
AppResult<T>sealed interfaceWhitelistItemTypeenum (CHANNEL, VIDEO, PLAYLIST)- Material 3 theme (colors, typography, shapes)
- Shared composables
core:data
The domain layer. Contains:
- 7 repository interfaces + implementations
- Domain models (
WhitelistItem,KidProfile,ParentAccount, etc.) YouTubeUrlParser— Parses various YouTube URL formatsSleepTimerManager— Background countdown timer (Singleton)TimeLimitChecker— Combines watch history with profile settings
core:database
Room database layer:
YouTubeWhitelistDatabase(version 3, 5 entities)- 5 DAOs (ParentAccount, KidProfile, WhitelistItem, WatchHistory, CachedChannelVideo)
- Type converters for enums
- Composite indices for performance
CachedChannelVideoEntity— composite PK(channelId, videoId)for lazy loading cachefallbackToDestructiveMigration(appropriate for pre-production)
core:network
YouTube API client + free endpoints (Hybrid Strategy E):
YouTubeApiService— 5 Retrofit endpoints (YouTube Data API v3)OEmbedService— Free video/playlist metadata (youtube.com/oembed, 0 quota)RssFeedParser— Free channel video listing (RSS/Atom feeds, 0 quota, XXE-protected)InvidiousApiService+InvidiousInstanceManager— Fallback API (round-robin instances, health tracking)- DTOs with
@Serializable(kotlinx-serialization) ApiKeyInterceptor— Appends API key to YouTube API requests only (@YouTubeApiOkHttp)@PlainOkHttp— Clean OkHttpClient for oEmbed/RSS/Invidious (no API key)- HTTP logging interceptor (debug builds only!)
- Built-in fallback API key for F-Droid compatibility
core:auth
Authentication subsystem:
GoogleSignInManager— Chrome Custom Tabs OAuth flowOAuthLoopbackServer— Local HTTP server for OAuth redirectEncryptedTokenManager— AES-256-GCM token storage (Tink)PinHasher— PBKDF2 PIN hashingBruteForceProtection— Exponential lockout after failed attempts
core:export
JSON backup/restore:
- Uses DAOs directly (avoids circular dependency with core:data)
- Import generates new UUIDs (prevents PK conflicts)
- Two strategies: Merge or Overwrite
7. Navigation System
Type-Safe Routes
Navigation uses Kotlin Serialization for type-safe route parameters:
// Route.kt
@Serializable
sealed interface Route {
@Serializable data object Splash : Route
@Serializable data object SignIn : Route
@Serializable data class KidHome(val profileId: String) : Route
@Serializable data class VideoPlayer(
val profileId: String,
val videoId: String,
val videoTitle: String = "",
val channelTitle: String? = null
) : Route
// ... 18 routes total
}
Navigation Graph
Splash (entry)
├─ First Run → SignIn → PinSetup → ProfileCreation → KidHome
├─ Returning User (single profile) → KidHome
└─ Returning User (multiple profiles) → ProfileSelector → KidHome
KidHome
├─ Search icon → KidSearch
├─ Channel card → ChannelDetail → VideoPlayer
├─ Video card → VideoPlayer
├─ Playlist card → PlaylistDetail → VideoPlayer
└─ Lock FAB → PinEntry → ParentDashboard
ParentDashboard (requires PIN)
├─ Manage Whitelist → WhitelistManager
├─ Browse YouTube → WebViewBrowser
├─ Sleep Mode → SleepModeScreen → KidHome (after timer starts)
├─ Edit Profile → ProfileEdit
├─ Watch Stats → WatchStats
├─ Export/Import → ExportImport
├─ Create Profile → ProfileCreation
├─ Change PIN → PinChange
├─ About → About
└─ Back to Kid Mode → KidHome
ViewModels with Runtime Parameters
ViewModels that need runtime parameters (like profileId) use Hilt’s AssistedInject:
@HiltViewModel(assistedFactory = KidHomeViewModel.Factory::class)
class KidHomeViewModel @AssistedInject constructor(
whitelistRepository: WhitelistRepository,
@Assisted private val profileId: String
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(profileId: String): KidHomeViewModel
}
}
// In AppNavigation.kt
val viewModel: KidHomeViewModel =
hiltViewModel<KidHomeViewModel, KidHomeViewModel.Factory> { factory ->
factory.create(route.profileId)
}
8. Database Schema
Entity Relationship
ParentAccount (1) ──→ (N) KidProfile (1) ──→ (N) WhitelistItem
(1) ──→ (N) WatchHistory
Entities
| Entity | Table | Primary Key | Foreign Keys | Key Indices |
|---|---|---|---|---|
ParentAccountEntity |
parent_accounts |
id (UUID) |
— | — |
KidProfileEntity |
kid_profiles |
id (UUID) |
parentAccountId → parent_accounts.id (CASCADE) |
parentAccountId |
WhitelistItemEntity |
whitelist_items |
id (UUID) |
kidProfileId → kid_profiles.id (CASCADE) |
kidProfileId, kidProfileId+type, kidProfileId+youtubeId (UNIQUE) |
WatchHistoryEntity |
watch_history |
id (UUID) |
kidProfileId → kid_profiles.id (CASCADE) |
kidProfileId, kidProfileId+watchedAt |
Key Database Design Decisions
- Composite unique index on
(kidProfileId, youtubeId)in whitelist items prevents duplicates at DB level - CASCADE delete on foreign keys: deleting a profile removes all its whitelist items and watch history
- UUID primary keys: Generated via
java.util.UUID.randomUUID().toString() - Version 2 with
fallbackToDestructiveMigration()— acceptable for pre-production
9. Dependency Injection
Hilt Module Organization
| Module | Location | Scope | Responsibility |
|---|---|---|---|
ApiKeyModule |
app/di/ |
SingletonComponent |
API keys from BuildConfig |
DatabaseModule |
core:database/di/ |
SingletonComponent |
Room database + DAOs |
NetworkModule |
core:network/di/ |
SingletonComponent |
OkHttp, Retrofit, API service |
AuthModule |
core:auth/di/ |
SingletonComponent |
Auth repos, PIN hasher, token manager |
DataModule |
core:data/di/ |
SingletonComponent |
Domain repositories, SleepTimerManager |
DispatcherModule |
core:common/di/ |
SingletonComponent |
Coroutine dispatchers |
ExportModule |
core:export/di/ |
SingletonComponent |
Export/import services |
Custom Qualifiers
@Qualifier annotation class YouTubeApiKey // YouTube Data API v3 key
@Qualifier annotation class GoogleClientId // OAuth client ID
@Qualifier annotation class GoogleClientSecret // OAuth client secret
Binding Pattern
Repositories use @Binds for interface-to-implementation mapping:
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds @Singleton
abstract fun bindWhitelistRepository(
impl: WhitelistRepositoryImpl
): WhitelistRepository
}
10. Network Layer (Hybrid Strategy E)
Architecture
Since v1.1.0, the app uses a hybrid network strategy with multiple data sources:
HybridYouTubeRepositoryImpl (main binding for YouTubeApiRepository)
├── OEmbedService (free, 0 quota) — video/playlist metadata
├── RssFeedParser (free, 0 quota) — channel video listing (first page)
├── YouTubeApiService (1 unit/call) — channels, pagination
└── InvidiousApiService (free fallback) — all operations when API fails
Fallback chain per operation: | Operation | 1st (Free) | 2nd (API) | 3rd (Fallback) | |———–|———–|———–|—————-| | Video metadata | oEmbed | videos.list | Invidious | | Playlist metadata | oEmbed | playlists.list | Invidious | | Channel metadata | — | channels.list | Invidious | | Channel videos (page 1) | RSS | playlistItems.list | Invidious | | Channel videos (page 2+) | — | playlistItems.list | Invidious | | Kid search | Room DB (local) | — | — |
API Service (Retrofit)
interface YouTubeApiService {
@GET("channels") suspend fun getChannels(...): Response<YouTubeListResponse<ChannelDto>>
@GET("videos") suspend fun getVideos(...): Response<YouTubeListResponse<VideoDto>>
@GET("playlists") suspend fun getPlaylists(...): Response<YouTubeListResponse<PlaylistDto>>
@GET("playlistItems") suspend fun getPlaylistItems(...): Response<YouTubeListResponse<PlaylistItemDto>>
@GET("search") suspend fun search(...): Response<YouTubeListResponse<SearchResultDto>>
}
API Quota Management
YouTube Data API v3 has a 10,000 units/day quota. Most operations now cost 0 units via free endpoints:
| Endpoint | Cost | Free Alternative | Notes |
|---|---|---|---|
channels.list |
1 unit | Invidious (fallback) | Only for channel + @handle resolution |
videos.list |
1 unit | oEmbed (0 cost) | oEmbed is primary source |
playlists.list |
1 unit | oEmbed (0 cost) | oEmbed is primary source |
playlistItems.list |
1 unit/page | RSS (0 cost, first page) | RSS for first 15 videos |
search.list |
Removed from kid mode | Local Room DB only (v1.1.0) |
Quota protection measures:
- oEmbed/RSS handle ~90% of operations at 0 quota cost
- Kid search is local-only (Room DB, 0 quota)
- Check for duplicates BEFORE making API calls
- Built-in fallback API key for F-Droid builds
OkHttp Configuration
Two OkHttpClient instances:
@YouTubeApiOkHttp—ApiKeyInterceptor+ debug-only logging (for YouTube API)@PlainOkHttp— No interceptors (for oEmbed, RSS, Invidious)- YouTube API base URL:
https://www.googleapis.com/youtube/v3/
URL Parser
YouTubeUrlParser handles all YouTube URL formats:
youtube.com/watch?v=VIDEO_IDyoutu.be/VIDEO_IDyoutube.com/@handleyoutube.com/channel/CHANNEL_IDyoutube.com/c/CustomName(resolved viaforHandleAPI)youtube.com/playlist?list=PLAYLIST_ID
11. Authentication & Security
OAuth Flow
1. User taps "Sign In"
2. App starts OAuthLoopbackServer (local HTTP server on random port)
3. Chrome Custom Tab opens Google OAuth consent page
4. User authenticates with Google
5. Google redirects to http://localhost:{port}/callback?code=AUTH_CODE
6. OAuthLoopbackServer captures the authorization code
7. App exchanges code for access_token + refresh_token
8. Tokens stored in EncryptedSharedPreferences (AES-256-GCM)
Why Chrome Custom Tabs, not WebView? Google has blocked OAuth in embedded WebViews since 2016. Chrome Custom Tabs is the only F-Droid-compatible option.
PIN Security
- Hashing: PBKDF2WithHmacSHA256 (not plaintext)
- Brute force protection: Exponential lockout after 5 failed attempts
- 5 failures → 15 min lockout
- 10 failures → 30 min lockout
- Increases with repeated failures
WebView Security
All WebViews in the app have security hardening:
webView.settings.apply {
allowFileAccess = false
allowContentAccess = false
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
safeBrowsingEnabled = true
}
Player WebViews additionally block ALL navigation:
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(...) = true // Block everything
}
Screen Pinning (Kiosk Mode)
Activity.startLockTask()when entering Kid ModeActivity.stopLockTask()when parent verifies PIN- Prevents Home/Recent button usage
12. State Management Patterns
Pattern 1: Reactive Multi-Source State
Used when UI depends on multiple Room Flow sources:
val uiState: StateFlow<UiState> = combine(
repository.getChannels(profileId),
repository.getVideos(profileId),
repository.getPlaylists(profileId)
) { channels, videos, playlists ->
UiState(channels = channels, videos = videos, playlists = playlists)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly, // Always active (required for tests)
initialValue = UiState()
)
Pattern 2: Nested combine() for 6+ Flows
Kotlin’s combine() supports max 5 flows. For more, nest them:
combine(
flow1, flow2, flow3, flow4,
combine(flow5, flow6) { a, b -> a to b }
) { f1, f2, f3, f4, (f5, f6) ->
// Build state from all 6 sources
}
Pattern 3: Debounced Search
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow() // Non-debounced for TextField
val results: StateFlow<List<Item>> = _query
.debounce(300)
.flatMapLatest { query ->
if (query.isBlank()) flowOf(emptyList())
else repository.search(profileId, query)
}
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
Important: TextField must use the non-debounced query state. Using debounced state causes the input to reset during the delay window.
Pattern 4: One-Shot API Calls
For operations that aren’t reactive (e.g., sign-in):
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun signIn() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = authRepository.signIn()) {
is AppResult.Success -> _uiState.update { it.copy(isSuccess = true) }
is AppResult.Error -> _uiState.update { it.copy(error = result.message) }
}
}
}
Pattern 5: Room Cache as Single Source of Truth (Lazy Loading)
Used for channel detail — API writes to Room, UI reads from Room Flow:
// Room Flow auto-updates UI when new videos are cached
val videosFlow = _searchQuery
.debounce(300)
.flatMapLatest { query ->
if (query.isBlank()) cacheRepository.getVideosByChannel(channelId)
else cacheRepository.searchVideosInChannel(channelId, query)
}
// API fetches write to Room, Flow auto-emits
fun loadMore() {
viewModelScope.launch {
val result = apiRepository.getPlaylistItemsPage(playlistId, nextPageToken)
if (result is AppResult.Success) {
cacheRepository.cacheVideos(channelId, result.data.videos)
// Room Flow auto-updates → UI receives new videos
}
}
}
Infinite scroll: LaunchedEffect(Unit) in trailing item{} triggers loadMore() when visible.
Pattern 6: Fire-and-Forget with Job Tracking
When mixing reactive Flow collection with one-shot API calls:
private var searchJob: Job? = null
val results: StateFlow<List<Item>> = _query
.debounce(300)
.flatMapLatest { query ->
searchJob?.cancel() // Cancel previous API call
searchJob = viewModelScope.launch {
// Fire-and-forget API call that updates _apiResults
}
combine(localResults, _apiResults) { local, api -> local + api }
}
.stateIn(...)
13. Testing Guide
Test Stack
| Library | Purpose |
|---|---|
| JUnit 4 | Test framework |
| MockK | Mocking (Kotlin-friendly) |
| Coroutines Test | runTest, advanceUntilIdle(), advanceTimeBy() |
| Turbine | Flow testing DSL |
| Truth | Fluent assertions |
| Robolectric | Android framework in JVM tests |
Running Tests
# All unit tests
./gradlew test
# Specific module
./gradlew :core:data:test
./gradlew :feature:kid:test
# With test output
./gradlew test --info
# Instrumentation tests (requires emulator/device)
./gradlew connectedAndroidTest
Test Structure Convention
Tests mirror the source structure:
src/main/java/.../repository/WhitelistRepositoryImpl.kt
src/test/java/.../repository/WhitelistRepositoryImplTest.kt
src/main/java/.../ui/home/KidHomeViewModel.kt
src/test/java/.../ui/home/KidHomeViewModelTest.kt
Writing ViewModel Tests
class KidHomeViewModelTest {
// MockK mocks
private val whitelistRepository = mockk<WhitelistRepository>()
private val kidProfileRepository = mockk<KidProfileRepository>()
@get:Rule
val mainDispatcherRule = MainDispatcherRule() // Replaces Dispatchers.Main
@Before
fun setup() {
// Setup mock return values
every { whitelistRepository.getChannelsByProfile(any()) } returns flowOf(emptyList())
// ...
}
@Test
fun `when profile has channels, uiState shows them`() = runTest {
val channels = listOf(testChannel())
every { whitelistRepository.getChannelsByProfile("profile1") } returns flowOf(channels)
val viewModel = createViewModel(profileId = "profile1")
advanceUntilIdle()
assertThat(viewModel.uiState.value.channels).isEqualTo(channels)
}
}
Writing Repository Tests
class WhitelistRepositoryImplTest {
private val whitelistItemDao = mockk<WhitelistItemDao>()
private val youTubeApiService = mockk<YouTubeApiService>()
private lateinit var repository: WhitelistRepositoryImpl
@Before
fun setup() {
repository = WhitelistRepositoryImpl(whitelistItemDao, youTubeApiService, ...)
}
@Test
fun `addItemFromUrl with valid video URL adds item`() = runTest {
coEvery { youTubeApiService.getVideos(any(), any()) } returns Response.success(videoResponse)
coEvery { whitelistItemDao.getByProfileAndYoutubeId(any(), any()) } returns null
coEvery { whitelistItemDao.insert(any()) } returns Unit
val result = repository.addItemFromUrl("profile1", "https://youtube.com/watch?v=abc123")
assertThat(result).isInstanceOf(AppResult.Success::class.java)
}
}
Testing Flow with Turbine
@Test
fun `items flow emits updated list when item added`() = runTest {
val items = MutableSharedFlow<List<WhitelistItem>>()
every { dao.getByProfile(any()) } returns items
repository.getItemsByProfile("profile1").test {
items.emit(emptyList())
assertThat(awaitItem()).isEmpty()
items.emit(listOf(testItem()))
assertThat(awaitItem()).hasSize(1)
}
}
Important Testing Notes
- Use
SharingStarted.Eagerlyin ViewModels —WhileSubscribedbreaks tests without active subscribers - Use
advanceTimeBy(n)for time-sensitive tests —advanceUntilIdle()processes ALL pending delays (including timers/debounces) advanceTimeBy(N)is boundary-exclusive — add+1ms for inclusive boundary tick- MockK relaxed mocks return non-null objects for nullable return types — always explicitly mock
returns nullwhen needed - Use
java.util.Base64(notandroid.util.Base64) in plain JUnit tests android.net.UrithrowsRuntimeExceptionin JVM tests — usejava.net.URLEncoderinsteadorg.json.JSONObjectrequires Robolectric (Android SDK class)
14. Build & Release
Build Variants
| Variant | isMinifyEnabled |
isShrinkResources |
Signing | App ID Suffix |
|---|---|---|---|---|
debug |
false | false | Debug keystore | .debug |
release |
true (R8) | true | Release keystore | — |
Version Management
In app/build.gradle.kts:
defaultConfig {
versionCode = 1 // Increment for every Play Store upload
versionName = "1.0.0" // Semantic versioning
}
Dependency Management
All dependencies are centralized in gradle/libs.versions.toml:
[versions]
kotlin = "2.1.0"
composeBom = "2025.01.01"
hilt = "2.53.1"
room = "2.7.0"
# ...
[libraries]
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
# ...
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
# ...
Usage in build.gradle.kts:
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
ProGuard/R8 Rules
Key rules in app/proguard-rules.pro:
- Keep all
@Serializableclasses and their$$serializercompanions - Keep Navigation Compose
Routesealed interface + subclasses - Keep export DTOs and YouTube API DTOs
- Keep
@JavascriptInterfacemethods (WebView bridges) - Keep Retrofit service interface methods and annotations
- Keep Room entities and DAOs
Release Build Commands
# Debug APK
./gradlew assembleDebug
# Release APK (for F-Droid / GitHub Releases)
./gradlew assembleRelease
# → app/build/outputs/apk/release/app-release.apk (2.4 MB)
# Release AAB (for Google Play Store)
./gradlew bundleRelease
# → app/build/outputs/bundle/release/app-release.aab (5.2 MB)
15. Code Style & Conventions
Naming
- Screens:
*Screen.kt— Top-level@Composablefunction - ViewModels:
*ViewModel.kt—@HiltViewModelclass - UiState:
*UiState— data class inside ViewModel file - Repositories:
*Repository.kt(interface) +*RepositoryImpl.kt(implementation) - Entities:
*Entity.kt— Room entity - DAOs:
*Dao.kt— Room DAO interface - DTOs:
*Dto.kt— Network response classes - Tests:
*Test.kt— Test class
Architecture Rules
- Feature modules depend on core modules, never on each other
- core:data depends on core:database and core:network (repository pattern)
- core:export depends on core:database directly (avoids circular deps with core:data)
- Domain models live in core:data (not in core:database or core:network)
- app module wires everything together (navigation, DI root)
Kotlin Style
- KSP for annotation processing (not kapt)
- kotlinx-serialization for JSON (not Gson/Moshi)
sealed interfacefor type-safe discriminated unions (AppResult,Route)data classfor immutable state objectsFlow<T>for reactive data streams from RoomStateFlow<T>for ViewModel state exposed to UI
16. Common Pitfalls & Lessons Learned
Android/JVM Test Compatibility
| Class | JVM Test | Solution |
|---|---|---|
android.util.Base64 |
Crashes | Use java.util.Base64 |
android.net.Uri |
Crashes | Use java.net.URLEncoder |
org.json.JSONObject |
Crashes | Use Robolectric or kotlinx-serialization |
Flow & Coroutines
flatMapLatestcancels the previous inner Flow but NOT standalonelaunchJobs — track Jobs manuallycombine()supports max 5 flows — nest combines for 6+stateIn(SharingStarted.Eagerly)is required for ViewModels used in testsadvanceUntilIdle()processes ALL delays including timers — useadvanceTimeBy()for precision
WebView
loadUrl("about:blank") + stopLoading() + clearHistory() + destroy()for proper cleanupCookieManager.flush()before WebView destroy to persist cookiessetAcceptThirdPartyCookies(webView, true)needed for YouTube login persistence- DisposableEffect key should match content ID (e.g.,
youtubeId) for cleanup on navigation - WebView ref: use
mutableStateOf<WebView?>(null), NOTmutableListOf(accumulates on recomposition)
YouTube API / Hybrid Network
- oEmbed/RSS free endpoints handle most operations at 0 quota cost (v1.1.0)
- Kid search is local-only (Room DB) — YouTube Search API removed from kid mode
- Check duplicates BEFORE API calls — saves quota
/c/CustomNameURLs → useforHandleAPI (YouTube maps these to@handles)- URL-decode query parameters in URL parser using
java.net.URLDecoder - Invidious fallback: Only penalize instances on IOException, not parsing errors
- RSS: Only works with channel IDs (not @handles), max 15 videos, no pagination
@Upsertin Room matches on PRIMARY KEY only — use composite PK for cache table
Hilt/DI
- Multiple
@Assisted Stringparams need@Assisted("identifier")to disambiguate @Keepannotation on JavaScript bridge classes to survive R8 in release builds- Android library modules need
buildFeatures { buildConfig = true }to accessBuildConfig.DEBUG
Room
- Always check for existing account before insert to prevent cascade FK deletion
- Domain models must include ALL entity fields (e.g.,
sleepPlaylistId) to prevent data loss on update - Composite unique index enforces DB-level duplicate prevention
17. Existing Documentation
| Document | Location | Content |
|---|---|---|
| README.md | Project root | Quick start, features, building |
| CHANGELOG.md | Project root | Release notes (Keep a Changelog) |
| GOOGLE_SETUP.md | Project root | GCP Console setup instructions |
| SIDELOADING.md | Project root | APK installation via adb |
| STORE_LISTING.md | Project root | Play Store / F-Droid descriptions |
| BRD.md | docs/ |
Business Requirements Document |
| FS.md | docs/ |
Functional Specification (18 screens, 15 FRs) |
| HLD.md | docs/ |
High-Level Design (architecture, tech stack, 8 Mermaid diagrams) |
| LLD.md | docs/ |
Low-Level Design (948 lines, components, 18 Mermaid diagrams) |
| ARCHITECTURE.md | Project root | Session archive index |
Recommended reading order for new developers:
- This onboarding guide (you’re here)
docs/HLD.md— Understand the big picturedocs/LLD.md— Detailed component specificationsdocs/FS.md— Functional requirements and screen inventory
18. Contribution Workflow
Development Process
- Understand the task — Read relevant docs and code
- Write tests first (TDD) — This project follows test-driven development
- Implement — Write the minimum code to pass tests
- Review — Self-review for security, performance, and correctness
- Test on device — Run on emulator or physical device
- Commit — Clear commit message describing the change
Adding a New Screen
- Define the route in
Route.kt:@Serializable data class NewScreen(val param: String) : Route - Create ViewModel in the appropriate feature module:
@HiltViewModel(assistedFactory = NewScreenViewModel.Factory::class) class NewScreenViewModel @AssistedInject constructor( repository: SomeRepository, @Assisted private val param: String ) : ViewModel() { ... } - Create Screen composable:
@Composable fun NewScreen(viewModel: NewScreenViewModel, onNavigateBack: () -> Unit) { ... } - Add route to
AppNavigation.kt:composable<Route.NewScreen> { backStackEntry -> val route = backStackEntry.toRoute<Route.NewScreen>() val viewModel = hiltViewModel<NewScreenViewModel, NewScreenViewModel.Factory> { it.create(route.param) } NewScreen(viewModel = viewModel, onNavigateBack = { navController.popBackStack() }) } - Write tests for the ViewModel
- Update ProGuard rules if new serializable classes are added
Adding a New Repository
- Define interface in
core:data/repository/ - Create implementation in
core:data/repository/impl/ - Add
@BindstoDataModule - Write tests
- Inject via Hilt in ViewModels
Key Files to Always Check
When making changes, ensure these stay consistent:
Route.kt— Navigation routesAppNavigation.kt— Route wiringproguard-rules.pro— R8 keep rules for new serializable/reflective classeslibs.versions.toml— Dependency versionsCHANGELOG.md— Document notable changes
Quick Reference Card
Build debug: ./gradlew assembleDebug
Build release APK: ./gradlew assembleRelease
Build release AAB: ./gradlew bundleRelease
Run all tests: ./gradlew test
Run module tests: ./gradlew :feature:kid:test
Clean build: ./gradlew clean
Package: io.github.degipe.youtubewhitelist
Min SDK: 26
Target SDK: 35
JDK: 17
Gradle: 8.11.1 (wrapper)
Kotlin: 2.1.0
Main entry: app/src/main/.../MainActivity.kt
Navigation: app/src/main/.../navigation/Route.kt
Database: core/database/src/main/.../YouTubeWhitelistDatabase.kt
API Service: core/network/src/main/.../api/YouTubeApiService.kt
Dependencies: gradle/libs.versions.toml
Secrets: local.properties (git-ignored)