Skip to content

Deva#68

Merged
sjg20 merged 25 commits intomasterfrom
deva
Mar 5, 2026
Merged

Deva#68
sjg20 merged 25 commits intomasterfrom
deva

Conversation

@sjg20
Copy link
Copy Markdown
Owner

@sjg20 sjg20 commented Mar 5, 2026

Summary

Fix several memory-safety issues found by AddressSanitizer: heap overflows
in the .max file decoder, use-after-free in Filemax::create(), dangling
pointers, and array delete mismatches. Also fix memory leaks in destructors
for Desktopwidget, Dirmodel, Desktopview, and Filejpeg.

Fix colour .max preview rendering — both the blank-page tinting and the
RLE-decoded thumbnails were producing transparent or black output due to
missing alpha bytes and incorrect pixel iteration. Add greyscale preview
encoding so 8bpp previews round-trip correctly, and auto-rebuild stub
previews on the fly.

Fix stale directory context after creating folders, which broke the
folder-suggestion feature in the scan dialog. Speed up post-scan cache
updates by inserting a single file instead of rescanning the whole tree.

Other changes: app icon for Play Store release, connection-timeout fix in
the Flutter app, site download links for Android/iPhone, and build/release
improvements (do-build fixes, parallelisation, make release target).

sjg20 added 24 commits March 5, 2026 10:06
Point users to pre-built .deb packages available from the GitHub
Releases page, as an alternative to the PPA and building from source.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
The local URL probe relies on Future.timeout() to give up after a few
seconds, but the underlying io.HttpClient has no connectionTimeout set.
On some platforms the OS-level TCP connect retries SYN packets for 30+
seconds to an unreachable local IP, making the app appear to hang.

Set connectionTimeout on the HttpClient so the socket layer itself gives
up quickly. Also reduce the default timeout from 2 seconds to 500 ms,
since anything reachable on the local network responds well within that.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
When a directory is created on the filesystem outside of paperman (or
since the cache was last built), refreshCache() calls findItemW() which
returns nullptr for the unknown path. The subsequent freeChildren() call
on the null pointer causes a segfault.

Handle this by dropping the stale cache and rebuilding it from scratch
when the directory is not found, matching the existing behaviour for
when no cache exists at all.

Add a test that reproduces the crash by creating a directory on the
filesystem bypassing paperman, then attempting to create a subdirectory
inside it.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Add testFindFoldersSuggestsMonth() which verifies that findFolders()
correctly suggests the next month directory after creating directories
through the app within the same session.

The test builds the cache, creates 01jan and 02feb via newDir(), then
checks that findFolders() suggests 03mar. This exercises the cache
update path to ensure the in-memory cache reflects newly created
directories.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
The uninit handler deletes _label but does not null the pointer, unlike
_progress which is correctly set to zero on the next line. If two
Operation objects are created and destroyed in sequence, the second
uninit attempts to delete the dangling _label pointer, causing a crash.

Add the missing null assignment.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
doNewDir() calls _model->mkdir() which internally calls
QDirModel::refresh(). This refresh invalidates the QPersistentModelIndex
_context in Dirview. Any subsequent operation that depends on _context,
such as getRootIndex() used by findFolders() in the pscan dialog,
silently returns an invalid index and produces empty results.

Save the parent directory path before mkdir and re-establish _context
afterwards via selectContextItem(). This matches the approach already
used in the test-friendly newDir(path, index) variant, which calls
selectDir() after mkdir.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
Add two tests that exercise the Desktopwidget::findFolders() code path,
which is used by the pscan dialog's F4 folder-search feature.

testFindFoldersSuggestsMonthViaDesktop simulates the real UI workflow:
set _context to bills/2026 as if the user right-clicked, then create
directories via doNewDir(), and verify that findFolders() still suggests
the next month. Before the previous fix, this test fails because
doNewDir() invalidates _context and getRootIndex() returns an invalid
index.

testFindFoldersSuggestsMonthAfterRefresh does the same but also calls
refreshDirmodelCache() between creating directories and searching, to
cover the case where the user manually refreshes the cache.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
The code stores a pointer from toLatin1().constData() in a local
variable, but the temporary QByteArray is destroyed at the end of the
statement, leaving a dangling pointer. The subsequent fopen() and
utilSetGroup() calls read freed memory.

Keep the QByteArray alive in a named variable and use constData() from
it instead.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
The compressed tile decoder in decode_compressed_tile() reads a 4-byte
word from the input buffer at each step. When the input pointer is near
the end of the buffer, this read overflows the allocation.

Add 4 bytes of zero padding when allocating the cache buffer so that
the 4-byte lookahead near the end of compressed tile data does not
cause a heap-buffer-overflow. The extra bytes are zero-filled by Qt's
resize() and only read data comes from fread() with the original size.

Found by AddressSanitizer.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
When insert_chunk() reuses an existing chunk slot that is larger than
the new chunk data, it expands new_chunk.size to match the old slot
size. However, it does not reallocate new_chunk.buf, leaving the buffer
at its original (smaller) size. When flush_chunks() later writes
chunk.size bytes from chunk.buf, it reads past the end of the buffer.

Reallocate the buffer to match the expanded size and zero-fill the
new portion.

Found by AddressSanitizer.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
When build_chunk() is called with force=true and chunk.buf already
exists, it recalculates chunk.size and allocates a new buffer via
alloc_chunk_buf() without freeing the old one. This leaks the
previous buffer each time a chunk is force-rebuilt.

Free the old buffer before allocating the new one for all three chunk
types (bermuda, annot, env). The existing TODO comments indicated the
developer was aware of this issue.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
Add an icon and prepare for a release on the Play store.

Signed-off-by: Simon Glass <sjg@chromium.org>
The colour_image_for_blank() function, which tints colour previews to
indicate blank pages, has two bugs that cause colour document previews
to display as blank white rectangles:

The rgb pointer is never incremented in the inner loop, so only the
first pixel of each row is ever processed. Also, the colour
calculation adds the component values together as a raw integer instead
of constructing a proper QRgb value with qRgb(). For a white pixel
this produces an integer ~701 which, interpreted as QRgb, has alpha=0
making the pixel fully transparent.

Add the missing rgb++ increment and wrap the calculation in qRgb() to
match the pattern used by the greyscale palette entries elsewhere.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
Colour (24bpp) preview thumbnails appear blank in the main view
because the alpha byte is written as 0 (fully transparent) instead of
0xff (fully opaque). The rle_decode() function expands 3-byte RGB
preview pixels to 4-byte RGBX, but sets the fourth byte to zero.

When this data is wrapped in a QImage with Format_RGB32 and converted
to QPixmap, the zero alpha causes every pixel to be transparent,
producing blank white thumbnails. Monochrome previews are unaffected
because they use Format_Indexed8 with palette entries from qRgb()
which includes alpha=0xff.

Change the alpha byte from 0 to 0xff so colour previews render as
opaque.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
The add_preview() function stores raw 8bpp preview bytes with a plain
memcpy(), but decode_8bpp_preview() expects a nibble-based run-length
format. This mismatch causes rebuilt greyscale previews to appear
completely black.

Add encode_8bpp_preview() which quantises each pixel to a 4-bit nibble
and emits runs in the format that the decoder expects. The decoder
inverts via "255 - value" to bridge the polarity difference between
image convention (0=black) and preview colour table (0=white), so the
encoder does not invert. Use it in add_preview() for the 8bpp case.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
The rebuildPreviews() function processes all pages in a batch, which is
not suitable for on-the-fly rebuilding of individual pages.

Extract the per-page logic into a new rebuildPagePreview() method that
handles a single page: it opens the file, loads chunks, decodes the
full image, builds a preview, and flushes. It returns early if the page
is not 8bpp greyscale or already has a real preview. Simplify
rebuildPreviews() to call it in a loop.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
Some old PaperPort .max files have stub previews (4 bytes or fewer)
for 8bpp greyscale pages. Rather than requiring a batch rebuild of the
whole database, detect these stubs at display time and rebuild
automatically.

When getPreviewPixmap() encounters a stub preview on an 8bpp page, it
frees any temporary chunk, calls rebuildPagePreview() to decode the
full image and generate a proper preview, then re-fetches the chunk
and continues with normal preview decoding. The rebuilt preview is
flushed to disk so this only happens once per page.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
Add two tests for the greyscale preview encoding:

testPreview8bppRoundtrip() creates a synthetic gradient, encodes it,
decodes it, and verifies each pixel is within the nibble quantisation
tolerance. It also checks that the decoder correctly inverts the
polarity (image 0=black becomes preview 255=black).

testPreviewFromJpeg() loads greyscale_gradient.jpg, scales it to
preview size, runs the encode/decode roundtrip, and checks the
qCompress'd result against a known size, catching any regression in the
encoding logic.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
The Filejpeg destructor is empty, so the Filejpegpage objects in
the _pages list are never freed. The remove() method does clean them
up, but that also deletes the files from disk and is not called on
normal destruction.

Add qDeleteAll() to the destructor to free the page objects.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
The Desktopview constructor allocates a Measure object but the
destructor is empty, so it is leaked.

Add delete to the destructor to free it.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
The constructor allocates _map with new but the destructor does not
free it, causing a memory leak.

Add delete to the destructor.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
The constructor creates _model (Dirmodel) and _dir_proxy (Dirproxy)
without a parent, so they are not auto-freed by Qt's object hierarchy.
The destructor does not free them either, causing a large leak of
the directory model and all its internal state.

Add delete calls for both, after the view is freed.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
setupJpeg() allocates _buffer with new[] but finishJpeg() frees it
with delete instead of delete[]. This is undefined behaviour and is
caught by AddressSanitizer.

Use delete[] to match the new[] allocation.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
After each scan completes, slotStackConfirm() calls
refreshDirmodelCache() which rescans the entire directory tree under
the scan path with utilScanDir(). This is visible as a progress bar
sweeping across the bottom of the window and causes unnecessary delay,
particularly for large directories.

Add a targeted addFileToCache() method that inserts just the new
filename into the existing in-memory cache tree and writes it out,
avoiding the full directory rescan. The full rescan remains available
via the "Refresh cache" context-menu action.

Also add an optional fname parameter to confirmScan() so the caller
can capture the scanned filename before it is cleared.

Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
The Android app is not on the Play Store, so change the link to
download the APK directly from the GitHub release. Also add a link
to the iPhone app on the App Store.

Co-developed-by: Claude Opus 4.6 <noreply@anthropic.com>
@sjg20 sjg20 closed this Mar 5, 2026
@sjg20 sjg20 reopened this Mar 5, 2026
@sjg20 sjg20 merged commit 746c83c into master Mar 5, 2026
@sjg20 sjg20 temporarily deployed to github-pages March 5, 2026 17:38 — with GitHub Actions Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant