Base URL: Configured via BuildConfig.API_BASE_URL (default: https://tv.cadnative.com/)
The Retrofit calls go through the OkHttp interceptor in NetworkModule, which adds:
Accept: application/json
X-TV-Code: <tv_code_from_settings> # the paired channel-list code; redacted in logs
Content-Type: application/json # only when the request has a body
Note: Device pairing and in-app-update calls do not use this Retrofit client or the
X-TV-Codeheader. They go throughPinnedHttpClient(a separate OkHttp instance) against the server URL fromAppPreferences.getServerUrl()(defaulthttps://tv.cadnative.com). See Device Pairing and App Updates below.
Fetch all channels.
Response: ChannelsResponse
{
"success": true,
"data": [
{
"channelId": "ch_123",
"channelName": "BBC World",
"channelUrl": "https://stream.example.com/bbc.m3u8",
"channelImg": "https://img.example.com/bbc.png",
"tvgLogo": "https://img.example.com/bbc-hd.png",
"channelGroup": "News",
"channelDrmKey": null,
"channelDrmType": null,
"tvgLanguage": "en",
"tvgCountry": "GB",
"tvgId": "BBCWorld.uk",
"tvgName": "BBC World News",
"isActive": true,
"metadata": { "language": "English" },
"alternateStreams": [
{ "streamUrl": "https://alt.example.com/bbc.m3u8", "quality": "720p" }
]
}
]
}Field names map via @SerializedName in ChannelDto (data/model/dto/ChannelDto.kt): channelId→id,
channelName→name, channelUrl→url, channelGroup→groupTitle, channelDrmKey→drmKey,
channelDrmType→drmType. logoUrl is derived client-side (prefers tvgLogo, falls back to channelImg).
alternateStreams[] (optional) carries backup stream URLs — the app loads them into the in-memory
StreamSlot queue in ErrorRecoveryManager; there is no separate fallback endpoint.
Fetch a single channel by ID.
Path parameter: id — Channel identifier
Response: ChannelDto (same structure as items in the channels array)
Fetch all channel categories.
Response: CategoriesResponse
{
"categories": [
{
"id": "news",
"name": "News",
"display_order": 1,
"channel_count": 45
}
],
"total": 12
}Pull server-stored favorites on app launch. Merged with local Room favorites (server timestamp wins on conflict).
Response: FavoritesResponse
{
"channel_ids": ["ch_123", "ch_456"],
"timestamp": 1710500000000
}Sync local favorites to server.
Request body: FavoritesRequest
{
"channel_ids": ["ch_123", "ch_456"],
"device_id": "device_abc",
"timestamp": 1710500000000
}Response: 204 No Content on success
Report a stream as dead, alive, or unresponsive. Called fire-and-forget from PlayerViewModel — never blocks playback.
Path parameter: id — Channel identifier
Request body: StreamStatusReport
{
"status": "dead",
"deviceId": "device_abc",
"timestamp": 1710500000000,
"errorMessage": "Connection refused"
}status:"dead"(5 retries exhausted),"alive"(stream confirmed working),"unresponsive"(buffering >30 s)deviceId:Settings.Secure.ANDROID_IDtimestamp: epoch milliseconds (Long), not an ISO string- Sent fire-and-forget from
StreamMetricsRepositoryImpl(also increments local counters in thestream_metricstable)
Response: Response<Unit> (body ignored)
Report a successful play event (stream produced frames for ≥10 s). Also used when an alternate stream played — the streamUrl field triggers server-side auto-promotion of that alternate to primary.
Request body: StreamPlayReport
{
"deviceId": "device_abc",
"timestamp": 1710500300000,
"proxyPlay": false,
"streamUrl": "https://alt-stream.example.com/live.m3u8"
}timestamp: epoch milliseconds (Long)proxyPlay:truewhen ExoPlayer fell back to the TV proxystreamUrl(optional): actual stream URL played; if it matches an alternate, server auto-promotes it to primary
Response: Response<Unit> (body ignored)
Bulk sync results from a ChannelHealthScanner batch. Called after each scan cycle.
Request body: HealthSyncRequest
{
"deviceId": "device_abc",
"results": [
{
"channelId": "ch_123",
"status": "alive",
"responseTimeMs": 320,
"timestamp": 1710500600000
}
]
}- Each item (
HealthSyncItem):channelId,status,responseTimeMs(Long?),timestamp(epoch msLong) - Bulk sync of a
ChannelHealthScannerbatch; server uses MongoDBbulkWritefor efficiency
Response: Response<Unit> (body ignored)
Fetch the electronic program guide for the paired channel list. Used by EpgRepositoryImpl to feed
the Guide grid and the player's now/next overlay. Programs are merged with any user-configured XMLTV
source and persisted to the epg_programs Room table (last-good fallback).
Path parameter: channelListCode — the paired TV code (AppPreferences.getTvCode)
Query parameter: hours — window length in hours (default 12)
Response: EpgGuideResponse
{
"success": true,
"channels": [
{
"channelId": "BBCWorld.uk",
"channelName": "BBC World News",
"tvgLogo": "https://img.example.com/bbc.png",
"programs": [
{
"title": "News Hour",
"description": "World headlines",
"start": "2026-07-15T10:00:00Z",
"end": "2026-07-15T11:00:00Z",
"icon": "https://img.example.com/newshour.png"
}
]
}
]
}start/endare ISO-8601 instants (parsed withInstant.parse)
Download the full channel playlist in M3U format.
Response: ResponseBody (plain text M3U content)
Fetch a demo channel-list code, offered on the pairing screen as a "try it" fallback.
Response: Map<String, String> — a JSON object whose code field is the demo channel-list code.
These calls use
PinnedHttpClient, not Retrofit. Base URL fromAppPreferences.getServerUrl().
Check for app updates.
Query parameter: currentVersion — current app version code (integer)
Request header: X-Session-ID: <tv_code> (the paired channel-list code)
Response: update payload; when updateAvailable is true the details live under latestVersion:
{
"success": true,
"updateAvailable": true,
"isMandatory": false,
"latestVersion": {
"versionName": "2.2.3",
"releaseNotes": "Bug fixes and performance improvements",
"apkFileSize": 15728640,
"downloadUrl": "https://tv.cadnative.com/api/v1/app/download"
}
}If this check fails, AppUpdater falls back to the GitHub Releases API
(GET https://api.github.com/repos/akshaynikhare/FireVisionIPTV/releases/latest, Accept: application/vnd.github+json)
and reads tag_name, body, and the APK assets[] (name, browser_download_url, size).
The APK itself is downloaded with the Android system DownloadManager (to
getExternalFilesDir(DIRECTORY_DOWNLOADS)/FireVisionIPTV.apk), and the completion receiver is
registered with RECEIVER_EXPORTED to fix downloads stalling on Android 13+.
These calls use
PinnedHttpClient, not Retrofit, and send noX-TV-Codeheader.
Request a new pairing PIN.
Request body:
{
"deviceName": "Fire TV Stick",
"deviceModel": "Amazon AFTKA"
}Response:
{
"success": true,
"pin": "123456",
"expiresAt": "2026-07-15T12:00:00Z"
}Poll pairing status.
Path parameter: pin — 6-digit PIN from the pairing request
Response (pending):
{
"paired": false,
"status": "pending"
}Response (paired):
{
"paired": true,
"status": "completed",
"channelListCode": "ABC123",
"username": "user@example.com"
}On success the app stores channelListCode as the TV code
(AppPreferences.setTvCode, plaintext — see ADR-003).
fun getChannels(): Flow<Result<List<Channel>>>
fun getChannelById(id: String): Flow<Result<Channel>>
fun getChannelsByCategory(category: String): Flow<Result<List<Channel>>>
fun searchChannels(query: String): Flow<Result<List<Channel>>>
suspend fun refreshChannels(): Result<Unit>
suspend fun addToFavorites(channelId: String): Result<Unit>
suspend fun removeFromFavorites(channelId: String): Result<Unit>
fun getFavoriteChannels(): Flow<Result<List<Channel>>>fun getCategories(): Flow<Result<List<Category>>>
fun getCategoryById(id: String): Flow<Result<Category>>
suspend fun refreshCategories(): Result<Unit>fun getFavoriteChannels(): Flow<Result<List<Channel>>>
fun isFavorite(channelId: String): Flow<Result<Boolean>>
suspend fun addFavorite(channelId: String): Result<Unit>
suspend fun removeFavorite(channelId: String): Result<Unit>
suspend fun toggleFavorite(channelId: String): Result<Unit>
suspend fun updateFavoriteOrder(channelId: String, newOrder: Int): Result<Unit>
suspend fun syncFavorites(): Result<Unit>
suspend fun pullFavoritesFromServer(): Result<Unit> // pulls & merges server favorites on app launchfun getPlaybackPosition(channelId: String): Flow<Result<Long?>>
suspend fun savePlaybackPosition(channelId: String, position: Long, duration: Long): Result<Unit>
suspend fun deletePlaybackPosition(channelId: String): Result<Unit>
fun getAllPlaybackPositions(): Flow<Result<Map<String, Long>>>
suspend fun clearOldPositions(keepCount: Int = 100): Result<Unit>suspend fun parsePlaylistFromUrl(url: String): Result<List<Channel>>
suspend fun parsePlaylistFromString(content: String): Result<List<Channel>>
suspend fun importChannels(channels: List<Channel>): Result<Unit>No bound implementation. Actual M3U / Xtream import is orchestrated inside
ChannelRepositoryImplusingM3uDataSourceandXtreamDataSource. Xtream stream URLs are stored with{username}/{password}placeholders (StreamUrlTemplate) and resolved fromEncryptedSharedPreferencesat use time; credentials are never persisted in Room.
suspend fun reportStreamDead(channelId: String, errorMessage: String?): Result<Unit>
suspend fun reportStreamAlive(channelId: String): Result<Unit>
suspend fun reportStreamUnresponsive(channelId: String): Result<Unit>
suspend fun reportStreamPlay(channelId: String, proxyPlay: Boolean = false, streamUrl: String? = null): Result<Unit>
suspend fun syncHealthResults(results: List<HealthSyncEntry>): Result<Unit>Each call bumps a local counter in the stream_metrics table and fires the matching endpoint
(report-status / report-play / health-sync) fire-and-forget.
suspend fun getNowNext(tvgId: String): Pair<EpgProgram?, EpgProgram?>
suspend fun ensureLoaded()
suspend fun refreshNow()
fun getNowNextIfCached(tvgId: String): Pair<EpgProgram?, EpgProgram?>? // synchronous; null if not hydrated
suspend fun getProgramsWindow(tvgIds: List<String>, from: Instant, to: Instant): Map<String, List<EpgProgram>>
suspend fun clearAll()fun getRecentSearches(limit: Int = 10): Flow<Result<List<String>>>
suspend fun saveSearch(query: String): Result<Unit>
suspend fun clearHistory(): Result<Unit>
suspend fun removeSearch(query: String): Result<Unit>fun getTheme(): Flow<String> // default: "dark"
suspend fun setTheme(theme: String): Result<Unit>
fun getGridSize(): Flow<Int> // default: 3
suspend fun setGridSize(size: Int): Result<Unit>
fun getFontSize(): Flow<Float> // default: 1.0f
suspend fun setFontSize(scale: Float): Result<Unit>
fun getAnimationSpeed(): Flow<Float> // default: 1.0f
suspend fun setAnimationSpeed(speed: Float): Result<Unit>
fun getLayoutDensity(): Flow<String> // default: "comfortable"
suspend fun setLayoutDensity(density: String): Result<Unit>
suspend fun clearCache(): Result<Unit>
// Back-press / player behaviour (added since v2.1)
fun getBackExitProtection(): Flow<Boolean>
suspend fun setBackExitProtection(enabled: Boolean): Result<Unit>
fun getPlayerKeyUpDownAction(): Flow<String>
suspend fun setPlayerKeyUpDownAction(action: String): Result<Unit>
fun getPlayerKeyLeftRightAction(): Flow<String>
suspend fun setPlayerKeyLeftRightAction(action: String): Result<Unit>
fun getPlayerLongOkAction(): Flow<String>
suspend fun setPlayerLongOkAction(action: String): Result<Unit>
fun getSleepTimerDefaultMinutes(): Flow<Int>
suspend fun setSleepTimerDefaultMinutes(minutes: Int): Result<Unit>
fun getAlwaysShowProgramBar(): Flow<Boolean>
suspend fun setAlwaysShowProgramBar(enabled: Boolean): Result<Unit>
fun getInfoBarTimeoutSeconds(): Flow<Int>
suspend fun setInfoBarTimeoutSeconds(seconds: Int): Result<Unit>| Use Case | Type | Input | Output |
|---|---|---|---|
GetChannelsUseCase |
Flow | Unit |
Flow<Result<List<Channel>>> |
GetChannelByIdUseCase |
Flow | String (channelId) |
Flow<Result<Channel>> |
GetChannelsByCategoryUseCase |
Flow | String (category) |
Flow<Result<List<Channel>>> |
RefreshChannelsUseCase |
Suspend | Unit |
Result<Unit> |
SearchChannelsUseCase |
Flow | Params(query, filters) |
Flow<Result<List<Channel>>> |
GetRecentSearchesUseCase |
Flow | Int (limit) |
Flow<Result<List<String>>> |
SaveSearchQueryUseCase |
Suspend | String (query) |
Result<Unit> |
ClearSearchHistoryUseCase |
Suspend | Unit |
Result<Unit> |
GetFavoriteChannelsUseCase |
Flow | Unit |
Flow<Result<List<Channel>>> |
ToggleFavoriteUseCase |
Suspend | String (channelId) |
Result<Unit> |
ReorderFavoritesUseCase |
Suspend | Params(channelId, newOrder) |
Result<Unit> |
PullFavoritesUseCase |
Suspend | Unit |
Result<Unit> |
GetPlaybackPositionUseCase |
Flow | String (channelId) |
Flow<Result<PlaybackState?>> |
SavePlaybackPositionUseCase |
Suspend | Params(channelId, position, duration) |
Result<Unit> |
ReportStreamStatusUseCase |
Suspend | Params(channelId, status, errorMessage?) |
Result<Unit> |
ReportStreamPlayUseCase |
Suspend | Params(channelId, proxyPlay, streamUrl?) |
Result<Unit> |
SyncHealthResultsUseCase |
Suspend | Params(results[]) |
Result<Unit> |
GetGuideProgramsUseCase |
Suspend | Params(tvgIds, from, to) |
Map<String, List<EpgProgram>> |
deviceId is resolved inside StreamMetricsRepositoryImpl (from ANDROID_ID), so the stream-report
use cases no longer take it as a parameter. Total: 18 use cases.
FireVisionDatabase is at version 8 (7 migrations). As of the v6→v7 migration the user-data tables
(favorites, channel_health, stream_metrics) dropped their foreign keys to channels so a full
channel resync never cascades away user data — the relationships below are logical, not enforced FKs.
erDiagram
channels ||..o{ favorites : "logical"
channels ||..o| channel_health : "logical"
channels ||..o| stream_metrics : "logical"
channels {
TEXT id PK
TEXT name "NOT NULL, INDEXED"
TEXT streamUrl "NOT NULL"
TEXT logoUrl
TEXT categoryId "NOT NULL, INDEXED"
TEXT language
TEXT country
TEXT groupTitle
TEXT tvgId
TEXT tvgName
INTEGER isActive "INDEXED, default 1"
INTEGER lastUpdated
}
favorites {
INTEGER id PK "AUTOINCREMENT"
TEXT channelId "UNIQUE INDEXED"
INTEGER addedAt
INTEGER displayOrder "default 0"
}
channel_health {
TEXT channelId PK
TEXT status "INDEXED"
INTEGER lastCheckedAt "INDEXED"
INTEGER responseTimeMs
TEXT errorMessage
TEXT thumbnailPath
}
stream_metrics {
TEXT channelId PK
INTEGER playCount "default 0"
INTEGER aliveCount "default 0"
INTEGER deadCount "default 0"
INTEGER unresponsiveCount "default 0"
INTEGER lastPlayedAt
INTEGER lastDeadAt
INTEGER lastAliveAt
INTEGER lastUnresponsiveAt
}
categories {
TEXT id PK
TEXT name
INTEGER displayOrder "default 0"
INTEGER channelCount "default 0"
}
favorite_categories {
INTEGER id PK "AUTOINCREMENT"
TEXT categoryName "UNIQUE INDEXED"
INTEGER addedAt
}
playback_positions {
TEXT channelId PK
INTEGER position
INTEGER duration
INTEGER lastPlayed "INDEXED"
}
search_history {
INTEGER id PK "AUTOINCREMENT"
TEXT query "UNIQUE INDEXED"
INTEGER timestamp "INDEXED"
}
epg_programs {
TEXT channelEpgId PK
INTEGER startTimeMs PK
INTEGER endTimeMs "INDEXED"
TEXT title
TEXT description
TEXT icon
}
None. All cross-table relationships are resolved in query/repository logic; foreign keys were removed in the v6→v7 migration so channel resyncs never cascade-delete user data.
| Table | Index | Columns |
|---|---|---|
channels |
index_channels_categoryId |
categoryId |
channels |
index_channels_isActive |
isActive |
channels |
index_channels_name |
name |
channel_health |
index_channel_health_channelId (unique) |
channelId |
channel_health |
index_channel_health_lastCheckedAt |
lastCheckedAt |
channel_health |
index_channel_health_status |
status |
stream_metrics |
index_stream_metrics_channelId (unique) |
channelId |
favorites |
index_favorites_channelId (unique) |
channelId |
favorite_categories |
index_favorite_categories_categoryName (unique) |
categoryName |
playback_positions |
index_playback_positions_lastPlayed |
lastPlayed |
search_history |
index_search_history_timestamp |
timestamp |
search_history |
index_search_history_query (unique) |
query |
epg_programs |
index_epg_programs_endTimeMs |
endTimeMs |