Releases: ahXN00/OwnTV_Core
Release list
OwnTV Core core-1.0.61
🐛 Fixes
- 📐 Category panel can go down to 10% when there are only two columns
- 🌐 French low-zoom warning synced with Weblate
OwnTV Core core-1.0.60
🐛 Fixes
- 🔒 Playlists on servers with the newest Let's Encrypt certificates import again (#208)
OwnTV Core core-1.0.59
Breaking
✨ New features
- 🖼️ Match EPG brings the guide's logo, with an "Include guide logos" choice
- ⚙️ Video player settings in smaller categories
🐛 Fixes
- 💾 A removed USB stick no longer stops downloads
- 📺 Channel logos on the Android TV home row are no longer cropped (community PR #6 by @quangtruongnb)
- 📃 Playlists saved with a byte-order mark keep their EPG address (community PR #7 by @Sekator778)
- 🔤 The EPG logo setting works on the phone
OwnTV Core core-1.0.58
DB v44 · DB v45 · DB v46 · Backup v23 · Backup v24 · API · Breaking · Strings. The playback
upgrade: a live tuner shared by both apps, pause and rewind on channels without catch-up, OwnTV's own
mpv engine, sound and picture settings, per-playlist and per-stream memory, safer restores, and a new
icon and logo in eight colours. New text is in every packaged locale.
A consuming app must:
- in
Application.onCreate, return at once whenAppIconSwitcher.isRestartProcess(this); assign
AppIconSwitcher.mainActivityClassbefore Koin; callAppIconSwitcher.start()and
PlaybackStartup.start(context, …)after it (Breaking); - build its engines with player-core's
ownTVPlayer()/livePreviewEngine()(theOwnTVPlayer
constructor gainedoriginalLanguage); - tune live channels through
LiveTuneController, and forwardonTrimMemorytoPlaybackEngines; - never declare libmpv itself — it arrives through
:player-core(tv.own.owntv:libmpv:2026.09.2).
Database v44 → v46
- v44:
playback_quirks— engine pins, sound-only marks and audio delays, shared by every profile
(the old DataStore files are copied in once and left on disk for one release);
playback_prefs.sourceId/audioLang/subtitleLang; five per-playlist columns onsources(catch-up
time zone and offset, Movies & Series player, Give up after, HTTP Referer). Deleting a playlist
deletes what was remembered for its items. - v45:
metadata_cache.originalLanguage, for the "Original language" audio choice. - v46: the never-written
playback_quirks.softwareDecodeis dropped. - Every migration is
CREATE/ADD/DROP COLUMNonly, with no transaction statement, and has a
BundledSQLiteDrivermigration test.
Backups (v23, v24)
- v23: a
playbackQuirksblock, the new playlist columns, and remembered track languages. - v24: preferred audio / subtitle language per profile.
- A restore skips profiles and playlists that are not on this device instead of landing on whoever has
the same number, and never leaves a channel pinned to both engines. - One bad setting no longer aborts a restore; out-of-range values are skipped or clamped.
- Backups record which device wrote them (a hashed id). Restoring or syncing from another device
keeps this device's hardware settings unless "Hardware settings from the other device" is ticked. - Backups now carry Multiview, the recording settings, pause-and-rewind and every new setting below.
SettingsBackupCoverageTestfails for any key with no backup decision.
Live TV
LiveTuneController(API): live routing, the fallback ladder and its give-up alarm, ExoPlayer ⇄
mpv handovers, the Prefer-HLS rung, HLS-redirect learning, the Stalker reconnect link, the TV
preview pane and Multiview tiles — one copy for both apps. A newer tune cancels an older one, so two
quick picks can no longer finish in the wrong order.- Pause and rewind on channels without catch-up (off by default; 15 / 30 / 45 / 60 min): the
channel is saved on the device while watched full screen and both engines play that copy, with the
existing rewind bar, gaps drawn. It is the only provider connection and always leaves ≥ 1 GB free.
The copy is kept 5 min after leaving ("Resume / Go live"), deleted after 2 min on another channel,
and wiped at every start. Not buffered: encrypted HLS, separate audio, two-track DASH, DRM, WebM.
Background catalogue paging and connection measurement step aside while a copy downloads. - Previous channel (
ChannelRecall): a player button, a remote-shortcut action, and headset /
notification "previous" on live. - A live channel on mpv that keeps dying ends on "Lost connection" after its reconnect budget; the
budget comes back only after 60 s of unbroken playback. Retry keeps the channel's declared container
and its Xtreamdirect_sourcefallback. - Catch-up: time zone per playlist and in quarter hours; a finished programme continues to the next
one or to live on both apps (CatchupContinuemoved to core); the software-decode lesson expires
after 14 days. - "Reset saved live TV player choices" and "Forget learned stream fixes".
Playback engines
- OwnTV's own mpv build,
tv.own.owntv:libmpv:2026.09.2(wasdev.jdtech.mpv:libmpv:1.0.0): mpv
master, FFmpeg 9.0.2, FFmpeg filters (so mpv's automatic deinterlacing runs), and the reason a file
ended — logged, and an app-issued stop is never treated as a failure. Same Java API. - One settings snapshot (
PlaybackSettings), awaited before the first tune after a cold start,
replaces ~30 per-setting subscriptions. - Sound: one volume scale on every engine (150 % ≈ +10 dB; the audio-focus duck is −12 dB);
audio sync on ExoPlayer too (live, Multiview tiles, films; per channel or film, shared by profiles);
night mode and volume levelling (off by default, both engines); "Dolby / DTS to the TV or receiver". - Picture: mpv hardware-decodes MPEG-2 and MPEG-4 Part 2; maximum video quality and a phone
mobile-data limit, plus a per-item Quality button; tunneled playback (experimental, off, ExoPlayer
live only, switches itself off after the first failure); the Deinterlacing setting is gone (mpv
deinterlaces by itself); HDR is labelled "mpv only"; auto frame rate on the phone (seamless only)
and TV extras (pause during the switch, match resolution). - Languages: preferred audio / subtitle language per profile, 50 languages, and "Original
language" from TMDB; the audio and subtitle choice is remembered per channel, film and series. - Films: buffer, network timeout and reconnect attempts settings; an ExoPlayer film reopens in
place before handing to mpv; step seeks snap to keyframes. - Sleep timer (
SleepTimer): 15–90 min, end of programme / film / episode, optionally switching
the screen off (ScreenOff, a device-adminforce-lockgrant). PlaybackEngines: memory pressure, Home and the screensaver reach every engine, Multiview tiles
included. On 2 GB TVs the preview pane plays at most 720p, measured stream stats default off and the
live buffer's byte ceiling is 2×.
Settings, network and startup
- Four per-playlist settings, with a Referer field in the playlist form.
- Proxy and custom DNS are in force from the first stream after a cold start. Custom DNS answers are
read correctly (plain DNS had silently fallen back to the system resolver); DNS-over-HTTPS uses the
binary form, so the Google and Quad9 presets work; A and AAAA are asked together and cached. - Startup work runs in core for both apps (
PlaybackStartup): the diagnostics switch, catch-up decode
memory and the one-shot settings migrations. - Last channel / category live in their own small store; Live TV and the Guide share one guide cache.
- Diagnostics log writes are thread-safe and off the calling thread.
Eight app icons and logos, one switcher
-
tv.own.owntv.core.brand.AppIcon(API):PETROL,SUNFLOWER,COBALT,TOMATO,
BOARD,EGGSHELL(default),OLIVE,OLIVE_CREAM. Each carries its name string, its flat in-app mark, its
≤32 dp mark, its TV banner, and the two accents of the wordmark's "TV". -
AppIconSwitcher(API): which launcher activity is enabled (applied), switching it (apply,
restartWith), the enabled one for anIntent(launchComponent), andstart(), which applies a
pending choice when the app goes to the background. A consuming app must assign
mainActivityClassbefore Koin, callstart()after it, and declare one launcher activity per colour
(MainActivity+AppIcon.activitySuffix, onlyMainActivityenabled). -
"Restart now" reopens the app.
restartWithhands over toAppRestartActivity, which runs in
a:restartprocess and task of its own, ends the old process and opens the new icon's activity in a
new task. Before this, the app was killed together with its own reopen request, and a reopen inside
the old task was closed by Android as soon as the switch applied (disabled-package). API:
AppIconSwitcher.isRestartProcess(context). -
SettingsRepository.appIcon/setAppIcon(keyapp_icon), included in the settings backup. -
Resources, generated from the pass-6 mockup by
tools/brand/render_brand.py:- launcher foregrounds (all densities, with the mockup's shadow) and adaptive icons with a shared monochrome layer;
- flat and small in-app marks;
- the launch screen: an 800 ms flip animation, a still card for Android 11 and older, and the wordmark as a branding image;
- 320×180 TV banners and the
owntv_notificationstatus-bar icon.
The old
drawable-xhdpi/tv_banner.pngis removed. -
The Android TV home channel's logo is the enabled icon's banner.
-
Core's download, EPG-sync and recording notifications use
owntv_notificationinstead of stock
Android icons. -
Strings: the App icon setting, its eight colour names and the restart question, in every
packaged locale. "Later" reusesupdate_later.
OwnTV Core core-1.0.57
Strings. A guide source that answers and turns out to be empty now says so, once, in every
packaged locale — instead of spinning for six minutes and then showing raw English or nothing at
all. No database, backup or API change.
A guide that has nothing to give is a final answer, not a network failure
Reported against a Stalker portal whose provider had stopped publishing EPG: Settings → EPG sat on
"Connecting…" for about six minutes per press of Re-sync, then failed. Measured against the live
portal, every guide route answers successfully and carries nothing — get_epg_info returns
{"js":{"data":[]}} (18 bytes) for every period from 1 to 14, get_short_epg returns {"js":[]}
for every channel sampled, and the panel's own xmltv.php answers HTTP 200 with a zero-byte body.
The portal is otherwise healthy — 11 539 live channels, 65 536 films, 21 945 series, and the 426
catch-up flags the Guide already shows.
Three things were wrong, all of them ours:
- The failure was classified as transient. "Portal returned no guide" is an
IOException, and
EpgSyncWorkerretried anything that was one — three more attempts with backoff, during which
WorkManager reports the job unfinished and the row reads "Connecting…". Nothing had failed, so
there was nothing to retry. The same held for a feed that downloaded and parsed in full but
carried no programme for the days kept, and for a portal guide whose playlist had since been
deleted. All three now have exception types of their own and are reported once. - The per-channel fallback never gave up. A portal with no guide accepts every channel and
answers with an empty list, so the failure counter never tripped and the full
MAX_PER_CHANNELbudget was spent proving it — 13 207 requests at one provider in a single
afternoon, enough to get a MAC blocked. It now stops after 25 channels have produced nothing at
all, and only while nothing at all has come back, so a lineup with genuine gaps is never cut short. - Neither case could be read. "Portal returned no guide" fell through to the raw-message branch
and was shown as English in all 25 packaged locales; the empty-feed case had no message whatsoever,
so the row showed no error and still read "Not synced yet". Both now classify to a new
FriendlySyncFailure.GuideEmptyand share one translated sentence,sync_error_guide_empty,
which says the provider may not have published the guide yet and to try again later — the common
cause, and one that usually fixes itself.
The entry stays on the EPG list either way. A guide that is merely late is the ordinary case, so
removing the source on an empty answer would take away the user's only way to fetch it once the
provider catches up.
OwnTV Core core-1.0.56
Fixes a crash that stops the app starting at all after upgrading a database that is still at
v40 — the version both apps shipped on core-1.0.42. No database, backup, API or string change:
the schema and every migration's result are exactly as in core-1.0.55.
A migration opened its own transaction, and the app could never start again
MIGRATION_40_41 — the one that fills epg_channels.normName / .normId — wrapped each batch of
its backfill in BEGIN IMMEDIATE TRANSACTION … COMMIT TRANSACTION. Room already runs the whole
migration chain inside one transaction, so that is a nested BEGIN, and SQLite refuses it:
android.database.SQLException: Error code: 1, message: cannot start a transaction within a transaction
Room then aborts the upgrade and retries it on the next open, so the failure is permanent — the
launcher icon opens a splash screen and the process dies, every time, with no way back short of
clearing the app's data.
Why it shipped green. Until core-1.0.49 Room was driven through Android's own SQLite engine,
and that engine's session layer intercepts a bare BEGIN / COMMIT / ROLLBACK and maps it onto
its own transaction API — so the statement was a no-op and the backfill simply ran inside Room's
transaction. core-1.0.49 moved to BundledSQLiteDriver, which hands the statement to SQLite
itself, which refuses it. OwnTVDatabaseMigrationTest kept building with AndroidSQLiteDriver,
so the one test written to prove this exact upgrade was still being run on the engine that hides the
bug.
The manual transaction is gone; the batching that keeps the row buffer bounded stays. Nothing is
lost by dropping it — an interrupted upgrade now rolls back rather than keeping whole batches, and
either way a NULL there simply means "normalize this one on the fly". OwnTVDatabaseMigrationTest
now opens with BundledSQLiteDriver, the engine production actually uses, so the same class of
mistake fails the test instead of the phone.
Who this affects. Anyone upgrading from a build pinned to core-1.0.42 or earlier — OwnTV TV
v5.0.0 and OwnTV Mobile v1.0.0 — straight to a build on core-1.0.49…1.0.55. A device whose
database is already past v41 never runs this migration and was never affected.
OwnTV Core core-1.0.55
Records MPEG-DASH live channels instead of quietly writing rubbish, and gives films and episodes the
Format row they never had. API — no database, backup or string change, and nothing that records
today changes behaviour.
An unprotected DASH channel is recorded, not looped into a reconnect storm
core-1.0.54 made these channels play. Recording one still fell through to the raw byte pump: the
manifest fetched fine, HlsMediaPlaylist.looksLikePlaylist did not match XML, and a few kilobytes of
MPD went into the recording file. read() then returned −1 — a manifest is a finite document — which
the pump reads as a dropped live stream and reports as NETWORK. NETWORK is not terminal, so the
engine waited, reconnected and appended the same manifest again, for the whole window. The
result was a file of hundreds of concatenated XML manifests, a reconnect storm against the provider,
one of the account's connections held the entire time, and a row that blamed the network. The same
shape as the DRM bug fixed in core-1.0.54, from the same cause: a document reaching a pump that
expects video.
RecordingEngine.attemptRecord now asks DashManifest.looksLikeDashManifest as well, on the body
and the content type both, and hands a manifest to the new recordDash.
The DASH recorder
DashManifest reads what a recorder needs and ignores what it does not — SegmentTemplate with
$Number$ or a SegmentTimeline, SegmentList, SegmentBase, BaseURL stacking, dynamic versus
static, minimumUpdatePeriod, availabilityStartTime, timeShiftBufferDepth, and
<ContentProtection>. Parsed with javax.xml.parsers rather than android.util.Xml, so every rule
in it is unit-tested without a device. Unknown elements are ignored, the stance HlsMediaPlaylist
already takes.
DashRecordingPlan holds the arithmetic: which Representations to record, which segments are due,
and how long to wait. Three decisions in it are deliberate.
- The Representations are fixed at the start and never followed. Highest bitrate wins, and a
quality that disappears mid-programme ends the recording with a reason rather than being replaced.
A resolution or codec change partway through is exactly what the mux cannot absorb. - Segments are identified by number, never by URL — the rule
recordHlsalready follows, because
several providers sign each segment individually and a URL cached for one cycle is a 403 in the
next. - A cycle is capped at 24 segments, and which end it takes from depends on the manifest. A live
stream keeps the newest, so a recorder coming back from a stall rejoins the edge instead of falling
further behind; a static window — catch-up — keeps the oldest, because those are the opening
minutes of the programme.
A live $Number$ template with no availabilityStartTime has no zero point to count from and is
refused rather than guessed at, which would request thousands of segments that were never published.
Two tracks, one file
DASH normally keeps video and audio in separate Representations, so concatenation — all HLS ever
needed — yields two half-files. DashRemux puts them back together with MediaExtractor and
MediaMuxer: plain android.media, no FFmpeg, no new dependency, and nothing that reaches across
into :player-core. Samples are copied untouched, interleaved by presentation time so the two stay
in step.
A Representation that already carries both is written straight into the recording, with no temp
file and no mux at all — the same path HLS takes.
A muxed recording is named .mp4, and the row's filePath moves with it. RecordingRules
chooses .ts because a transport stream plays while it is being written and survives being cut off;
the muxed file is neither, and a file manager, a media scanner and every external player go by the
extension. RecordingRules.muxedNameOf is the single rule. Consuming apps need no change — they
already read filePath from the row.
An interrupted recording is finished on the next run
The mux runs once, at the end. MediaMuxer cannot append to an existing MP4, so remuxing
periodically would mean redoing the whole recording each time — sixty passes over a two-hour
programme. The temp files are the crash-proof part instead: each is a valid fragmented-MP4 stream,
flushed a segment at a time. recoverInterruptedDashRecordings runs once per drain, before anything
starts, and turns the ones left behind by a crash or a battery death into a playable recording. If
the mux itself fails, the captured bytes are kept rather than deleted, and tried once more there.
A recording whose window is still open when the app restarts is not resumed — it restarts, losing
what it captured before the crash. Resuming would need certainty that this run picks the same
Representations as the last one, and nothing on disk records what those were.
New: RecordingDao.running(), a @Query only — no schema change, no migration, v43 stands.
Films and episodes finally show a Format row
ExoSubtitleEngine.streamInfo() emitted Video, HDR, bitrate, Audio and buffer rows and no Format
row at all, so the Stream info overlay had no Format line on VOD. Live knew its answer because it
chose the container; VOD handed the URL to Media3 and never asked what it concluded.
StreamFormatLabels is now the one vocabulary for all three engines — HLS, DASH, MPEG-TS,
MP4, MKV. StreamRoute.formatLabel reads its three values from there, VOD derives its label from
the container Media3 actually resolved, and mpv was tidied to match: it reported FFmpeg's raw
demuxer name, so a film read MOV,MP4,M4A,3GP,3G2,MJ2 and an MKV read MATROSKA,WEBM. The same film
now reads MP4 whichever engine is playing it. Anything still unrecognised keeps mpv's existing
raw-name fallback rather than showing a blank row.
MP4 and MKV are deliberately not StreamRoute entries: they are containers, not routes, and
there is no MKV route to tune.
Tests
81 new tests in :core covering the manifest reader, the scheduling arithmetic, the routing decision
and the failure modes, and 13 in :player-core for the label vocabulary — two of which pin that mpv
and ExoPlayer produce the same string for an MP4 and for an MKV.
OwnTV Core core-1.0.54
Plays DRM-protected and plain MPEG-DASH live channels, which previously failed before the first
frame on both apps. DB v43, API, Strings — no backup change, and no channel that plays
today changes route.
DASH channels play
A playlist can publish a channel at an address that says nothing about its container —
https://host/live/mpd/173, no extension — and only redirect to the real …/render.mpd once asked.
Media3 picks its media source before that redirect, and the choice was binary: HLS or progressive.
DASH had no way in at all, even though media3-exoplayer-dash was already on the classpath. Every
such channel was handed to the progressive extractor, which sniffed an XML manifest and stopped with
ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED before the first frame. With DRM in play the item is
pinned to ExoPlayer — mpv has no CDM — so there was no fallback rung left and the channel simply
died.
StreamRoute { HLS, DASH, PROGRESSIVE } replaces that boolean, and LivePreviewEngine.routeFor()
makes the choice from every piece of evidence at once, most specific first. A DASH route names
MimeTypes.APPLICATION_MPD on the MediaItem, which is all DefaultMediaSourceFactory needs to
build a DashMediaSource — carrying the DRM session manager the item already had.
Three independent ways in, because no single one covers every source type:
- The playlist's own declaration.
#KODIPROP:inputstream.adaptive.manifest_typewas parsed and
thrown away; it is now read intoManifestType { MPD, HLS, ISM }and stored. This makes the
first attempt correct, with no failed try.ismis stored but deliberately not routed —
media3-exoplayer-smoothstreamingis not a dependency, so it keeps exactly today's behaviour. - The response itself.
isDashResponse()recognisesapplication/dash+xml,
video/vnd.mpeg.dash.mpd, or a final URL whose path ends.mpd, and re-opens the same URL as
DASH. This is the only route for Stalker (the portal hands back its owncmd) and Xtream (the
live URL is one core builds as.ts) — neither can ever carry a declaration. The lesson is
remembered per panel, so one channel's discovery spares the rest. - VOD too. A protected film or episode published the same way now routes identically.
Stream info reported DASH channels as MPEG-TS
The overlay's Format row was if (isHls) "HLS" else "MPEG-TS" — two values, so a third route fell
into the else and read as raw TS. It stayed wrong even on a tune that never opened, which is what
users were screenshotting. It now reports HLS / DASH / MPEG-TS from the route actually taken.
A last-resort address for an Xtream channel that will not open
ChannelEntity.directSource stores the panel's own direct_source, when it publishes one. It is
never tuned first and never replaces streamUrl: panels build that field from the streaming
server's configured domain and fall back to its raw IP, so a misconfigured panel or load balancer
publishes an address only reachable inside their own network — which is why every major client
ignores it. It is tried once, last, after every other rung is spent, where the alternative is an
error screen. Used that way it can only add channels that would otherwise fail, never take one away.
Refused for a request refusal (429/458), which a different address cannot answer.
Recording a DRM channel is refused instead of attempted
RecordingFailure.DRM_PROTECTED, checked before the request is made. A protected channel used to
fall through to the raw byte pump, which wrote the provider's response into the file, hit
end-of-body, reported NETWORK — a reason that is not terminal — and so reconnected and appended
again for the whole length of the programme. The user got an unplayable file, the provider got hours
of reconnects, and one of the account's connection slots was held throughout by a recording that
could never succeed. The CDM decrypts only into a secure decoder for immediate display, so there is
no point at which the frames exist in the clear to write down; the reason is terminal for the same
reason ENCRYPTED is.
Kept distinct from ENCRYPTED, which is HLS transport encryption (#EXT-X-KEY, usually plain
AES-128) found inside a playlist that had to be fetched first. Telling a user with an AES-128 channel
that it is DRM-protected would be false.
Database
v43 — manifestType on channels, movies and episodes; directSource on channels.
Additive ALTER TABLE only, NULL on every existing row, and a NULL row behaves exactly as before, so
an upgraded install is unchanged until its playlist is re-synced. No table rewrite even on a
170k-item catalog. Both new fields fold into computeContentHash only when non-null, so no
existing hash moves and no catalog is rewritten on the next sync.
For a consuming app
OwnTVPlayer.play(), PlaylistItem and LivePreviewEngine.play() take manifestType, and
LivePreviewEngine.play() also takes directSource. Both default to null, so an app that passes
neither compiles and behaves exactly as before — but a DASH channel only routes correctly on the
first attempt if the app passes channel.manifestType, and the last-resort rung only exists if it
passes channel.directSource.
OwnTV Core core-1.0.53
Adds the settings and strings behind the TV app's second Movies & Series layout. Strings, API
— no database or backup change, and no existing behaviour moves.
A layout choice for Movies & Series
SettingsRepository.VodLayout { SEPARATE, CINEMATIC }, with vodLayout: Flow<VodLayout> and
setVodLayout(), keyed vod_layout. It defaults to SEPARATE — the three-panel layout both apps
draw today — so nothing changes for an existing install until the user picks otherwise. Modelled on
the existing vodViewMode, and it rides with the settings backup alongside it.
The TV app uses it to draw the focused title's TMDB backdrop behind the whole browse screen with a
read-only detail block above a poster grid. Core carries only the preference and the text; the
layout itself is the consuming app's.
The Cinematic detail block's height is its own setting, not a panel share
cinematicDetailsHeight(section) / setCinematicDetailsHeight(section, percent), per section, keyed
cinematic_details_movies and cinematic_details_series, defaulting to
CINEMATIC_DETAILS_DEFAULT (35) and capped at CINEMATIC_DETAILS_MAX (60).
Deliberately not folded into PanelShares. Those three are one row's widths and must total 100;
a height sharing that budget means a taller detail block can only be bought by narrowing the
posters, and a stored 0 can never mean 0 because something has to be left for the other two. Both
constants live in PanelWidths.kt next to PanelWidthLimits, so a consumer resolving the layout
reads the same ceiling the repository clamps to. Backed up with the other panel numbers.
Strings
Eleven new keys in strings_settings.xml, base plus every packaged locale that needs them: the
layout row, its description and the chooser's subtitle; both options with a description each; the
settings-search keywords; and settings_panel_width_content_area,
settings_panel_width_details_height and settings_panel_width_details_hint for the panel-width
screen, whose second and third sliders mean something different once Cinematic is on.
values-en-rGB is deliberately untouched — it is a sparse override carrying only British spellings,
and none of the eleven has one.
OwnTV Core core-1.0.52
Three fixes, all reproduced on a real phone against a real television before being written.
A guide sync could never finish with the screen off, and restarted from zero every time
EpgSyncWorker was a plain background worker. On a ColorOS phone the OEM battery manager freezes
the process 29 seconds after the display dims — and blacklists its network at the same time:
OplusHansManager: freeze uid:10056 tv.own.owntv.mobile pids:[…] scene: LcdOff
OAppNetControlService: Hans update:[10056=true] blackList:[… 10056]
The download dies mid-parse. On the next unfreeze WorkManager starts the work again from the first
byte, and because store.setSynced only runs on a clean finish, the source never stops being
stale. A large feed on a phone with a 30-second screen timeout therefore loops forever.
The worker now promotes itself to a foreground service with an ongoing notification, which is what
its siblings DownloadWorker and RecordingWorker have always done — same
FOREGROUND_SERVICE_DATA_SYNC permission, same SystemForegroundService, no manifest change. The
promotion is best-effort: where Android 12+ refuses a foreground service started from the
background, the sync still runs exactly as it did before. One notification per source, because EPG
work is unique per source and two feeds can sync at once. No new strings — the notification reuses
settings_syncing_guide, common_nav_guide and the existing programme-count plural.
Consuming apps need do nothing, but a user who has denied notifications will not see the
notification; the service still runs.
Local sync handed out a key that did not open the container it was serving
LocalSyncManager.startHosting assigned sessionPassword the moment the key was minted, then spent
seconds exporting the container it belonged to. With a listener already running from an earlier
hosting session, /sync/hello answered with the new key while /backup.own still served the
previous file. The receiving device downloaded a container it could not open, previewImport
threw, and the screen said "Something went wrong" and nothing else.
Verified from outside the app: the advertised session key failed the AES-GCM tag on the served
container, and succeeded against a freshly started hosting session.
The key is now published in startServing, alongside the file it opens, so the two can no longer
disagree. WrongPasswordException is also classified as SyncFailure.BadPayload rather than
falling through to Unknown, so the user is told "What arrived could not be read" instead of
"Something went wrong".
The first-run restore took the whole backup file, with nothing to say about it
SourceImporter.importBackup had no sections parameter, so both wizards restored everything —
while Settings → Backup & Restore and the local-sync setup step have always offered the tick-list.
"My playlists but not that device's settings" could not be expressed on the one screen where a
restore is most likely.
importBackup and restoreWithPassword now take sections, defaulting to all of it, so an app
that passes nothing behaves exactly as before.
For consuming apps: additive only, no signature breaks. Both apps pass a choice as of this
version.