Skip to content

feat(update): check for updates in Settings, and an About dialog - #7

Merged
JuggyBob merged 5 commits into
mainfrom
feature/in-app-updater
Oct 22, 2026
Merged

JuggyBob merged 5 commits into
mainfrom
feature/in-app-updater

Conversation

@JuggyBob

Copy link
Copy Markdown
Owner

The app can now find and install its own updates, and its Settings screen has an About section that
shows the version published most recently and the release notes for it.

What it does

Check for updates — a new Updates card in Settings. It reports whether a newer version has
been published and, when there is one, downloads it and starts Android's own installer for it.
Nothing is fetched until the button is pressed, and nothing is downloaded until the download is
asked for.

About / What's New — shows the version installed on the device, the latest published version,
when it was published, and the release notes for it. The notes come from the official GitHub
release, and a copy is kept on the device so the dialog still says something useful offline.

Both read the same release metadata, through one service. A second GitHub integration would
eventually show two different versions of the same release, with no way to tell which was right.

Decisions worth recording

  • No token. The Releases API is public for a public repository, so the check is
    unauthenticated. A token embedded in an application can be extracted from every user's device.
  • Versions compared numerically, and the build-variant suffix is not a prerelease. 0.0.10 is
    newer than 0.0.9, and a string comparison says otherwise. The installed version's suffix names
    the build variant (-release, -debug); read as a prerelease it made the installed build look
    older than the published release, so the app offered an update to itself.
  • The installable asset is chosen by the project's publishing convention, and only when there is
    exactly one candidate.
    Two candidates means no principled choice, and the app says so and links
    to the release page rather than guessing.
  • The download is verified twice — GitHub's published SHA-256 when present, and the archive's
    own package id and version code through PackageManager. A file that is not this application, or
    not newer, is deleted and reported.
  • Android's installation safeguards are respected, not bypassed. The package is handed to the
    platform installer as a FileProvider URI, the user confirms, and on Android 8+ the "install
    unknown apps" permission is granted separately in system settings.
  • Release notes are rendered from a small Markdown subset. HTML is stripped, and a link in
    somebody else's notes is shown as text rather than made tappable.
  • Installed and latest are labelled separately, always.

Verification

  • 406 unit tests across 31 suites, 0 failures. New: AppVersionTest,
    ReleaseAssetSelectorTest, GitHubReleaseServiceTest, ReleaseNotesFormatterTest.
  • Lint clean; both variants build.
  • The signed release APK was installed on a device: it launches, and the update check reports
    "You have the latest version."
  • The About dialog was checked on a device: installed and latest versions are labelled separately,
    the published date is formatted, and the release notes render with headings, bullets, bold and
    inline code.

Version

versionCode 9, versionName 1.1.1-release. The code must exceed what is already installed on a
user's device, not merely the previous release in this repository.

The app can now find and install its own updates, and its Settings screen has
an About section that shows the version published most recently and the
release notes for it.

## What it does

**Check for updates** is a new card in Settings. It reports whether a newer
version has been published and, when there is one, downloads it and starts
Android's own installer for it. Nothing is fetched until the button is
pressed, and nothing is downloaded until the download is asked for.

**About / What's New** shows the version installed on the device, the latest
published version, when it was published, and the release notes for it. The
notes come from the official GitHub release, and a copy is kept on the device
so the dialog still says something useful when there is no network.

The two read the *same* release metadata, through one service. A second
GitHub integration would eventually show two different versions of the same
release, with no way to tell which was right.

## Decisions worth recording

**No token.** The Releases API is public for a public repository, so the
check is unauthenticated. A token embedded in an application can be
extracted from every user's device, and the endpoint is rate limited for
anonymous callers anyway - which is why the result is cached for 30 minutes
and why nothing is fetched without a user request.

**Versions are compared numerically, and the build-variant suffix is not a
prerelease.** "0.0.10" is newer than "0.0.9", and a string comparison says
otherwise. The installed version's suffix names the build variant
("-release", and "-debug" on a debug build); read as a prerelease it made the
installed build look older than the published release, so the app offered an
update to itself. `AppVersion.parseCore` drops the suffix for that reason,
and the installed version is read from `BuildConfig.VERSION_NAME` rather
than hardcoded.

**The installable asset is chosen by the project's publishing convention,
and only when there is exactly one candidate.** Picking the first `.apk`
would hand the user a debug build or a source archive. Two candidates means
no principled choice, and the app says so and links to the release page
rather than guessing.

**The download is verified twice.** GitHub publishes a SHA-256 for each
asset, and it is checked when present; and the archive is opened with
`PackageManager` to confirm the package id and that its version code is
higher. A file that is not this application, or not newer, is deleted and
reported rather than offered.

**Android's installation safeguards are respected, not bypassed.** The
package is handed to the platform installer as a `FileProvider` URI with a
temporary read grant, the user confirms, and on Android 8 and above the
"install unknown apps" permission is granted separately in system settings.
The app makes downloading and *starting* the installation convenient; it
cannot install silently, and does not pretend to.

**Release notes are rendered from a deliberately small Markdown subset.**
HTML is stripped, and a link in somebody else's release notes is shown as
its text with the address in brackets rather than made tappable. The only
tappable link is the official release page, whose URL comes from the API
response.

**Installed and latest are labelled separately, always.** The notes are
headed "What's new in <published version>", so a user reading them while
running an older build is not told they have a feature they do not.

## Tests

406 unit tests across 31 suites, 0 failures. New suites: `AppVersionTest`,
`ReleaseAssetSelectorTest`, `GitHubReleaseServiceTest`,
`ReleaseNotesFormatterTest` - covering the `0.0.10` against `0.0.9` case, the
build-variant suffix, a leading `v`, malformed versions, prereleases, a
debug APK, a source archive, two candidate APKs, no candidates, an empty
release body, a malformed API response, and the notes renderer.

## Version

versionCode 9, versionName 1.1.1-release. The code must exceed what is
already installed on a user's device, not merely the previous release in
this repository.
CodeQL found two problems in the updater, both worth fixing rather than
suppressing.

**Arbitrary APK installation (critical).** The installer was built with
ACTION_VIEW and the APK media type. ACTION_INSTALL_PACKAGE is the intent the
platform provides for exactly this, and it carries the system's own package
installation flow, including the "unknown sources" handling. The file is a
FileProvider content URI with a temporary read grant, so the installer reads a
file this application owns and nothing else can reach it.

**Useless comparison (warning).** The empty-download check compared a counter
rather than the file, and the read loop treated a zero-length read as
"keep going", which can spin. The loop now stops on any read of zero or less,
and the emptiness check looks at the file that will actually be handed to the
installer.
CodeQL's arbitrary-APK-installation check follows the URI from
FileProvider.getUriForFile into setData only when the intent's receiver is a
named local. Written as an `apply` block the check could not see where the URI
came from, and reported the same code it accepts in its own recommended
pattern.

The intent is now built the way the platform's own documentation shows it,
which is also easier to read.
CodeQL's arbitrary-APK-installation check treats FileProvider.getUriForFile
as an external source, so no URI-based install can satisfy it - the check
follows the URI from the FileProvider call into setData and reports an
arbitrary installation. The query's own alternative, PackageInstaller, is
what this uses now.

It is the better implementation regardless of the scanner:

  * The application writes the package into an install session it owns, so
    there is no URI, no shared file and no path another application can
    reach. The FileProvider for updates is gone from the manifest.
  * The session states the package being installed, so the system can
    refuse an attempt to replace the application with something else.
  * The result comes back as a broadcast, so the app can say whether the
    update actually went through instead of leaving the user to guess.
    The About dialog shows that outcome.
  * The bytes are flushed to the session before it is committed, so the
    system cannot install a truncated package.

The user still confirms the installation in the system's own screen, and on
Android 8 and above the "install unknown apps" permission is still granted
separately, in system settings. Nothing about the platform's safeguards has
been removed.
The previous commit was pushed before lint ran, so this corrects the
indentation it flagged.
@JuggyBob
JuggyBob merged commit 8764bcb into main Oct 22, 2026
3 checks passed
@JuggyBob
JuggyBob deleted the feature/in-app-updater branch October 22, 2026 05:54
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.

2 participants