Thumbnails and previews¶
The app shows pictures in three places: the small thumbnail in a file's row, the preview on the details page, and the fullscreen view. Everything that shows a file works from a URL: Coil (the image loader) and ExoPlayer (the video and audio player) both take one. This page explains where those URLs come from, how the login reaches them, and what the app reads out of files itself.
Paths below are under app/src/main/java/tools/senko/materialdrain/ unless they name a provider-* module.
The image loader¶
The app uses Coil 2.7 (io.coil-kt:coil-compose and coil-gif). There's one ImageLoader for the whole
process, set up in AppContainer's init with Coil.setImageLoader. Every AsyncImage and context.imageLoader
uses it.
coil.Coil.setImageLoader {
coil.ImageLoader.Builder(application)
.components {
add(tools.senko.materialdrain.ui.media.MediaCovers.Factory(application))
add(tools.senko.materialdrain.ui.media.AnimatedImages.Factory())
}
.okHttpClient { okHttpClient.newBuilder().readTimeout(30, java.util.concurrent.TimeUnit.SECONDS).build() }
.build()
}
| Part | What it adds |
|---|---|
Media |
A Fetcher for audiocover:// URLs: a song's cover or a video's frame, read from the file. See Covers and frames |
Animated |
A Decoder for GIFs and animated WebP, so they move wherever they're shown |
ok |
The API's own client, so thumbnails reuse the HTTP/2 connection already open to the host. It gets a 30-second read timeout of its own: the API client has none (for long downloads), and a stalled thumbnail should give up |
Animated images¶
AnimatedImages recognizes an animated file by its first bytes (GIF87a/GIF89a, or a WebP with a VP8X header
whose animation flag is set), not by the type the host reports. Hosts often report none, or a generic one.
Android's ImageDecoder handles nearly all of them, but turns down some slightly broken GIFs that browsers still
play. So, for files up to 64 MB, the decoder tries in turn: ImageDecoderDecoder, then Coil's older GifDecoder (for
GIFs), then BitmapFactoryDecoder, which shows the first frame as a still picture. Bigger files go to ImageDecoder
alone, which streams them instead of reading them whole first.
The login for media¶
A private file's URL only works with the host's login. Coil and ExoPlayer don't know about hosts, so the app adds the
headers itself. HostRequestAuth (in ui/media/MediaPlayerSupport.kt) holds one function, which AppContainer
points at the active host:
HostRequestAuth.headersFor = { url ->
providerRegistry.resolve(providerConfigStore.activeProviderId.value).requestHeaders(url)
}
Every place that loads a file asks it: imageRequest in ImagePreview.kt, the thumbnail in FileListItem,
createMediaPlayer for ExoPlayer, and MediaCovers for the retriever. Each host's requestHeaders(url) only answers
for its own private URLs and returns nothing for any other address, so a login never goes to a site it doesn't belong
to. See Data and security.
S3 is the exception: it needs no headers, because its file URLs are presigned (the signature is in the URL itself).
Thumbnails¶
Which thumbnail a file gets¶
The file rows (browser/BrowserScreen.kt), the search results and the details page all ask
FileInfoViewModel.thumbnailFor(node) which picture to show. The rules, in order:
- SMB: whatever the host gives. An SMB share makes real thumbnails itself, on the device. See SMB files by address.
- Audio: an
audiocover://coverURL for the file's own album cover. A song without one keeps the app's music tile, not the host's generic picture. - Video: an
audiocover://frameURL for a frame read from the file. The host's thumbnail is the fallback, for a video the device can't read either. - HEIC, HEIF and AVIF photos: the file itself (
rawContentUrl). Hosts' thumbnailers often can't read these formats, so the phone decodes the file and Coil scales it down to the row's size. - Everything else: the host's thumbnail, but only for images and videos. For text, archives, apps and unknown kinds, hosts hand out a generic picture that says nothing. The app's own tile, coloured for the kind of file, says more.
// Only pictures and videos have a thumbnail worth showing: for everything else (text, archives, apps, unknown
// kinds) the hosts hand out a generic picture, and the app's own tile for the kind of file is used instead
val mime = node.previewMimeType().orEmpty()
return thumbnail.takeIf { mime.startsWith("image/") || mime.startsWith("video/") }
previewMimeType() uses the type the host reports, unless it's missing or application/octet-stream; then it works
the type out from the file extension with MimeTypeMap.
The tile is always drawn first, and the thumbnail goes on top of it (FileIcon in ui/components/FileListItem.kt).
There's no separate placeholder or error picture: if the thumbnail fails, the tile is simply still there.
What each host gives¶
This is thumbnailUrl(node) of each host, before the rules above are applied:
| Host | thumbnail |
|---|---|
| Pixeldrain | / for a file with an id; / for a filesystem file |
| REST API | The thumbnail_ endpoint for a file with an id, or browse_ for a path. None without them |
| WebDAV | An image is its own thumbnail (the file's URL); Coil scales it down. Nothing for other files |
| S3 | None |
| SMB | A / URL of Smb, for images, videos and audio |
Covers and frames from the file¶
ui/media/MediaCovers.kt makes thumbnails for hosts that stream their files over HTTP(S). It builds URLs like this:
audiocover://cover?src=<file URL>&v=<modified date>
audiocover://frame?src=<file URL>&fallback=<host thumbnail>&v=<modified date>
v is the file's modified (or created) date, so a changed file gets a new thumbnail. Coil hands these URLs to
MediaCovers.Factory, whose CoverFetcher:
- Looks in its disk cache, keyed by the SHA-1 of the source URL and
v. A cached.jpgis the thumbnail. A cached.nonemeans the file was read before and has nothing. - Otherwise opens the file with
MediaMetadataRetriever.setDataSource(url, headers). The media framework fetches only the byte ranges it needs, not the whole file. At most two files are read at once (aSemaphore(2)), because each takes a decoder and several requests. - For a song, takes the
embeddedPicture. For a video, takes the key frame nearest to a tenth of the way in (past a fade-in or a title card), but never more than 30 seconds in. If that gives nothing, it tries the key frame before, then the very first frame. - Scales the picture to 320 px on its longest side and saves it as a JPEG.
- With nothing to show, fetches the
fallback(the host's thumbnail, with its login) through Coil's normal fetchers, or fails so the tile stays.
Only a file that was read and really has no picture is remembered as .none. A read that failed (the network, a slow
host) is tried again next time.
Why read a frame when the host has a thumbnail?
Some hosts give a video they can't read (an unusual codec, an index at the end of the file) a generic picture of a film. The app can't tell that picture from a real thumbnail. Reading a frame on the device fixes most of them, and the host's picture is still the fallback.
Thumbnails are written as a .part file and then renamed, so one cut short by a crash is never read back.
Caching¶
| Cache | Where | What |
|---|---|---|
| Coil's memory and disk caches | Coil's defaults | Every image Coil loads: thumbnails and previews. The app doesn't configure them |
| Song covers | cache |
Made by Media |
| Video frames | cache |
Made by Media. A new folder: the first version also remembered videos that only failed to load once, and never tried them again |
| SMB thumbnails | cache |
Made by Smb, kept between runs |
| Media streaming | cache |
Exo: a Simple of up to 100 MB, least recently used first out |
All of them are in the app's cache folder, so Android can clear them when space runs low.
util/ExoPlayerCache.kt creates its SimpleCache once per process (a SimpleCache can't be opened twice on the same
folder). createMediaPlayer wraps the OkHttp data source in a CacheDataSource with FLAG_IGNORE_CACHE_ON_ERROR, so a
cache problem falls back to the network instead of failing playback.
Previews¶
The details page (files/FileDetailsScreen.kt) picks one preview by the file's previewMimeType():
| The file is | Shown with | File |
|---|---|---|
An archive (zip, 7z, rar, tar, tgz, apk) on a host that can look inside |
Archive |
files/ |
image/ |
Inline |
ui/ |
video/ |
Inline, then the fullscreen player |
ui/ |
audio/ |
Audio, the song's card |
ui/ |
A PDF (application/, or a . name) |
Inline |
ui/ |
| Markdown | Markdown |
ui/, parsed by files/ |
| Other text | Code |
ui/ |
| Anything else with a thumbnail | The thumbnail in a card |
The badge at a preview's corner opens it over the whole screen in FullScreenFileDialog (ui/media/FullScreenPreview.kt):
one dialog for every kind, a page per file, in the app's dark colours whatever theme it's in. With Swipe between
files, a swipe sideways goes to the next file of the same kind (FileGroup), which comes in like a page; a page the
data saver held back loads as soon as it's shown, as going to it is asking for it.
Images¶
LayeredPreviewImage shows an image in two steps. The thumbnail (small, and usually cached already) appears right
away, blurred, as a backdrop. The full image fades in over it once it has loaded, with a thin progress bar until then.
The thumbnail is always blurred, never shown as if it were the picture. Pixeldrain's thumbnails are made at a fixed
size, so they're framed differently from the full image; blurred, they're just its colours, and nothing seems to jump
when the full image arrives. Blurring needs Android 12; before that the backdrop is only made faint. The setting
Blurred backdrop (AppSettings.blurredBackdrop) turns it off.
Fullscreen¶
A picture's page of FullScreenFileDialog (and FullScreenMediaPreviewDialog, the Upload screen's, which only has
pictures and videos) puts it in a ZoomableBox: pinch to zoom, drag to pan, double-tap to zoom in and out, tap beside
the picture to close. The image is decoded at most 3072 px on a side (maxPixels), so a huge photo doesn't fill the
memory. Videos open FullscreenVideoPlayer.
Images use FilterQuality.None in the preview and fullscreen, so pixel art and zoomed-in pictures stay sharp instead
of blurry.
Video and audio¶
Both players use ExoPlayer from Media3, made by createMediaPlayer in ui/media/MediaPlayerSupport.kt:
- A remote file streams over the API's OkHttp client (one connection pool, HTTP/2), through the cache above, with the
host's headers from
HostRequestAuth. A local file (on the Upload screen) is read directly. - Playback starts after one second is buffered (the default waits for two and a half) and buffers up to 50 seconds ahead.
rememberManagedPlayerties the player to the composable: it pauses when the app goes to the background and is released when the composable leaves.
AudioPlayerPreview is created with prepareNow = false, so nothing loads until you press play. A song you only
glance at costs no data. The inline video preview is just the thumbnail with a play button; the player starts in
fullscreen.
MediaControls.kt has the shared controls: MediaIconButton, MediaTextButton (e.g. the playback speed) and
MediaSeekBar, which grows thicker while pressed, seeks on a tap and scrubs on a drag.
The video player (FullscreenVideoPlayer) takes its touches itself: a tap shows or hides the controls, a double
tap skips back or on at the sides (by Skip by, SEEK_STEPS) and plays or pauses in the middle, holding it plays at
2× (HOLD_SPEED), a pinch zooms up to 4× (MAX_VIDEO_ZOOM). With Brightness and volume swipes, a swipe up or down
changes the brightness on the left and the volume on the right, and sideways seeks; without them, a sideways swipe is
left to the swipe between files. Its settings menu (VideoSettingsMenu) has Loop (one choice for all videos,
AppSettings.loopVideos), Speed, Subtitles and Audio where the video has them, and Open in another app.
The rotate button turns the activity, which doesn't restart for it (configChanges in the manifest), so the video
goes on. Where each video was left is kept in util/VideoPositions.kt (the last 200, in preferences of their own,
not backed up).
Songs in the background¶
With Play songs in the background, a song plays in PlaybackService (ui/media/PlaybackService.kt), a Media3
MediaSessionService that outlives the details and the app's screen, with its controls in the notification and on
the lock screen. The details' song card reaches it through a MediaController (rememberPlaybackController); the
item's id is the song's address.
- The queue (
SongQueue.kt): a song played from its details takes the songs of the list it was opened from, in its order, up to 100 on each side (QUEUE_REACH). Add to queue puts more at its end.BackgroundSongfollows the player's song and queue for the screens, with its repeat mode and shuffle; the service shuffles anew each time shuffle is turned on, with the song playing first, and takes a queue rearranged while shuffled as a command of its own. - Tags (
SongTags.kt): a song's title, artist and album, and what kind of file it is, are read out of the file the way its cover is (only the parts needed), a couple of songs at a time, and kept on disk. The songs around the one playing are read ahead, so a skip shows the next title at once. - Pictures:
CoverBitmapLoadergives the notification and lock screen their covers through the app's own image loader, which reads what Media3's can't (audiocover://, a host's pictures behind its login); a song without one shows its file tile (rememberPlaceholderArt). - The miniplayer (
MiniPlayer.kt,BackgroundMiniPlayer) floats above the navigation bar. It follows the finger: sideways skips (the next song peeking in beside it), down closes the song (closeSong, with an Undo where that's on), up pulls the player open.AudioPlayerView(AudioPlayerView.kt) is the player over the whole app, or the compact card the miniplayer grows into (CompactMorphmoves the cover and title from the one's place to the other's);AudioPlayerSheetStatekeeps how far open it is, and survives the activity being made again. - Exported, but only so far: the service is exported, as the system's media controls need to reach it. Another app connected to it can play, pause, skip and seek, but not choose what plays, so it can't have the player load an address of its choosing.
Text¶
FileInfoViewModel.fetchTextFilePreviewContent decides whether a file is text: a text MIME type, an extension that
SourceHighlighting.kt knows, or application/octet-stream with an extension like .log, .ini, .conf or .md.
Then:
| Limit | Value | Constant |
|---|---|---|
| Largest file that's fetched | 1 MB | MAX_ |
| Shown on the details page | The first 8 KB of text, cut at the last whole line | MAX_ |
| Shown in fullscreen | Everything fetched |
A bigger file shows "File is too large … for text preview" instead. The whole file is downloaded into memory through
the same downloadNodeTo as a normal download, so it works for every host.
On the details page, CodePreview shows the start in a 16:9 card, the size of the other previews, scrolling inside,
with what it is and how long ("JSON · 120 lines", "first 30 of 400 lines") over its bottom corner. While it loads it's
the same card, so nothing below it moves when the text comes in. Its badge opens CodeFullscreenContent: numbered
lines, wrapping or sideways scrolling (starting from the Wrap long lines setting), Find in the text (each match
lit, stepped through with the arrows), bigger or smaller text, and copying all of it, or the lines picked with a long
press on a line's number and a tap on another. Only the lines on screen are laid out, and the colouring is worked out
off the main thread, so long files stay smooth; past MAX_COLOURED_LENGTH a text is shown without colours.
A Markdown file is shown as it's meant to look: parseMarkdown (files/Markdown.kt, plain Kotlin) turns it into
blocks (headings, lists with tasks, quotes, code, tables), which MarkdownPreview draws in the same card, and full
screen with a button to its source. Links to the web open in the browser.
The Upload screen has its own, simpler preview (InlineTextPreview in ui/media/TextPreview.kt): the first 4096
characters of the picked file.
Syntax highlighting¶
files/SourceHighlighting.kt is plain Kotlin, with no Android in it, so it's tested on the JVM
(SourceHighlightingTest, RichHighlightingTest, LongInputHighlightTest). It has two parts:
- The language list,
SourceLanguages.byExtension: which extension is which language. This is the part to edit when a file type is missing. A file whose extension isn't listed can still be recognized by a#!first line (byShebang:python,bash,node, …). - The tokenizer,
tokenize(code, language): finds comments, strings, numbers, keywords, types, calls, keys and so on. It's a plain scan, character by character with no regular expressions, so a very long input can't overflow the stack. It isn't a parser: a string simply ends at its quote or at the end of its line.
CodePreview.kt colours the tokens with one of two sets of colours, for light and dark backgrounds. The theme's own
colours are too few and too close together to tell a type from a call from a key.
Archives¶
A file named .zip, .7z, .rar, .tar, .tgz or .apk is opened as an archive when the host has ArchiveOps
(Pixeldrain, with its ?zip_info and ?zip_file API; a REST API config with archive_info and archive_file
endpoints) and ArchiveOps.canOpen says it can reach this one (FileInfoViewModel.showsArchive): Pixeldrain only can
for the files of its filesystem, not those of the account. The tab the details were opened from can also leave it out
(ARCHIVE_BROWSE in its disabled_capabilities). The archive itself is never downloaded just to look inside, and the
listing is read off the main thread.
ArchiveContents shows the folder you're in, its folders to open and its files to save, and a button to go up. Files
inside can only be saved, not opened: downloadArchiveEntry starts an ordinary download, and downloadNodeTo reads
the entry with ArchiveOps.read.
A host with no ArchiveOps of its own, whose files can be read in parts (WebDAV, S3, and SMB through the app's own
server), gets the app's own for zips (DeviceZipArchives, files/DeviceArchives.kt; .zip, .cbz, .jar, .apk).
It reads only the zip's directory at its end, in a few range requests, and for a file of it, only that file's part.
Stored and deflated files are read, as nearly every zip has; a file with a password isn't. A host that doesn't answer
for parts of a file can't be looked inside this way, which is said rather than the whole archive being downloaded.
PDFs¶
Android's PdfRenderer draws a PDF's pages, but only from a whole file on the device, so a PDF is loaded into the app's
cache first (FileInfoViewModel.fetchPdfPreview, through the same downloadNodeTo as a download):
| Limit | Value | Constant |
|---|---|---|
| Loaded without being asked for | 16 MB; a bigger one waits for a tap (Pdf) |
MAX_ |
| Largest that's loaded at all | 64 MB, also when the host doesn't tell the size (Limited stops the copy) |
MAX_ |
Kept in cache |
The last 5, by host, file, size and date, so a changed file isn't shown from an old copy | PDF_ |
A held-back preview (the data saver) counts a tap on it as asking, whatever the size. A copy that left the cache while the details were open (the cache was cleared) is loaded again instead of being called damaged.
PdfPreview.kt opens the file with a PdfDocument, which reads every page's size once, so pages take their place before
they're drawn, and draws one page at a time behind a Mutex (PdfRenderer can only have one page open). A page is
drawn at most 2560 px wide and 8 million pixels, on white, as on paper. A damaged page with no width or height counts as
square instead of crashing the layout.
- In the details,
InlinePdfPreviewis a card like the other previews: the top of the first page, the number of pages, and a tap opens it full screen. While it loads it shows how far along it is. - Full screen,
PdfFullscreenContent(a page ofFullScreenFileDialog) shows every page in aLazyColumn, scrolled with a finger and zoomed with two (or a double tap, up to 5×). Zooming lays the pages out wider rather than stretching them, so scrolling keeps up with the finger, and each page is drawn again, sharp, once the zoom has settled (200 ms). - A password-protected PDF can't be opened by
PdfRenderer; it says so instead.
SMB files by address¶
An SMB share has no web address for its files. But everything that shows a file in the app works from a URL: Coil
for thumbnails and pictures, ExoPlayer for video and audio, MediaMetadataRetriever for covers. So the app gives
each file an address itself, from a tiny HTTP server on the phone: SmbContentServer in provider-smb.
How it works¶
The server listens on 127.0.0.1 only, on a port Android picks (ServerSocket(0, 50, 127.0.0.1)). It starts the
first time a URL is asked for. Each connection is handled on a thread from a pool.
Every URL starts with a token: 16 random bytes from SecureRandom, made fresh each time the app starts.
http://127.0.0.1:<port>/<token>/raw/<host id>/<path>
http://127.0.0.1:<port>/<token>/thumb/<host id>/<path>?v=<modified date>
| Request | Answer |
|---|---|
Not GET or HEAD |
405 |
| A wrong or missing token | 403 |
| An unknown host id | 404 |
/ |
The file, with Content-, Content- and Accept- |
/ with Range: bytes=… |
206 Partial Content with Content-, or 416 for a range outside the file |
/ |
A JPEG (or a PNG when the picture has see-through parts), with Cache- |
Range requests are what make a video seekable. When you drag the seek bar, ExoPlayer asks for the bytes from that
point on, and the server reads them with SmbRandomAccessFile.seek instead of everything before. Only the first range
of a multi-range request is served; players ask for one.
Each SmbStorageProvider registers its SmbClient under its host id, and its thumbnailUrl and rawContentUrl
return these addresses.
Thumbnails on the device¶
For /thumb/, the server makes a thumbnail of up to 320 px from the file on the share:
| Kind | Extensions | How |
|---|---|---|
| Image | jpg, jpeg, png, webp, gif, bmp, heic, heif, avif |
Read whole and scaled down. Not for files over 40 MB, which would all have to come over the network |
| Video | mp4, m4v, mkv, webm, mov, 3gp, avi, ts |
A frame one second in, read with Media through an Smb, which reads only the parts it needs |
| Audio | mp3, m4a, flac, ogg, opus, aac, wav |
The embedded cover |
Images are decoded with ImageDecoder, which turns them the way their EXIF data says. Some HEIC photos from phone
cameras are refused by ImageDecoder but read by BitmapFactory, so that's tried next. At most three thumbnails are
made at once. Each is saved in cacheDir/smb-thumbnails (set by AppContainer), keyed by the SHA-1 of the host id,
the path and v. A thumbnail that can't be made is not tried again until the app restarts.
Cleartext, only for 127.0.0.1¶
Android blocks plain http:// by default. app/src/main/res/xml/network_security_config.xml allows it for exactly
one address:
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
Everything else stays HTTPS-only. Traffic to 127.0.0.1 never leaves the phone, so there's nothing to encrypt.
Why a server?¶
The alternative would be a second code path for SMB in every place that shows a file: a Coil fetcher, an ExoPlayer
DataSource, a retriever source, each with its own seeking and caching. With an address, SMB files go through
exactly the same code as files from any other host. Coil caches them, ExoPlayer streams and seeks them, and the
previews and the fullscreen view need no changes.
Why is that safe?
- Only this phone can reach it. It's bound to 127.0.0.1, not to the Wi-Fi network.
- Only this app knows the token. Another app on the phone could find the port, but without the token every
request gets
403. The token is new on every start, so an old URL stops working. - It only reads.
GETandHEADare the only methods; there's no way to change a file through it.
See also¶
- Uploads and downloads for
downloadNodeTo, which text previews and archive entries also use. - SMB for the SMB config fields.
- Hosts in code for
thumbnailUrl,rawContentUrlandrequestHeaders.