Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ release version + date and opens a fresh `[Unreleased]`.

## [Unreleased]

### Changed
- **Updates are now downloaded in your browser instead of being installed inside
NOVA** — NOVA still tells you when a new version is out and shows its release
notes, the file size and its SHA-256. "Download" now lets your browser download
the new APK; you install it by opening the downloaded file, and Android may ask
you once to allow your browser or file manager to install apps. NOVA no longer
asks for, or holds, the permission to install apps itself. Why: on a real phone
Google Play Protect stopped every current NOVA build with "Harmful app blocked"
(you could only continue through "More details > Install anyway"), while a build
of this version without that permission was rated "This app seems safe". Play
Protect may still offer to scan each new version before it installs; that is
expected. Versions up to 0.1.46 still install updates themselves, so the update
to this version can show the warning one last time (#306, #307).

### Added
- **The Play Protect notice is documented** — the README and the website now
explain what Google Play Protect shows when you install or update NOVA, why, and
how to check a download (SHA-256 and signing certificate).

## [0.1.46] - 2026-11-13

Adding a self-signed server now asks about its certificate only once, and the Overview shows dashes instead of zeros when it has no data. The 0.1.46 beta confirmed this on a real phone: the trust question appears once, the Overview shows dashes while offline, Uptime is a duration and disk rows have no dangling separator.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Trailing digit is required (`v0.2.0-beta` without a number is rejected by the re
| New end-to-end feature touching install + UI flow | Beta-first required (ADR-0006). |
| Anything affecting how the app talks to the Unraid server | Beta-first required (ADR-0006). |

When in doubt: beta-first. The in-app updater (ADR-0008) means betas reach the dev's device with no extra friction.
When in doubt: beta-first. The update notice (ADR-0046) announces each beta to the dev's device and sends the browser to the APK, so betas reach it with little friction.

### Cleaning up old releases

Expand Down
11 changes: 5 additions & 6 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ _Toolchain raised in the 0.1.28 cycle — see ADR-0023 (AGP-9) / ADR-0024
- Coil **3.6.3** for AsyncImage
- okhttp **5.5.1**, kotlinx.coroutines **1.11.0**, serialization **1.13.0**
- Renovate (gradle + github-actions, monthly, no auto-merge; `renovate.json`); Dependabot only for security alerts and security updates (ADR-0025)
- Distribution flavors `direct` (GitHub APK; declares `REQUEST_INSTALL_PACKAGES`, in-app updater enabled) and `store` (the F-Droid build; updater disabled at runtime via `BuildConfig.HAS_UPDATER = false`) — ADR-0040
- Distribution flavors `direct` (GitHub APK; update check enabled, updates are downloaded in the browser, ADR-0046) and `store` (the F-Droid build; updater disabled at runtime via `BuildConfig.HAS_UPDATER = false`) — ADR-0040
- Tests: JUnit 4; Robolectric 4.17 + Roborazzi 1.78 only for the store screenshots (`-PnovaScreenshots`); Gradle wrapper pinned by `distributionSha256Sum`

## Layout
Expand All @@ -121,9 +121,8 @@ app/src/main/
│ ├── repository/ UnraidRepository (domain streams: polling + subscriptionOrPoll, mutations via runAction, logs),
│ │ ServerRepository (CRUD + active server), SettingsRepository,
│ │ DomainState, Nudge (refresh nudges), ResponseClassifier
│ └── update/ UpdateRepository, UpdateInstaller, UpdateController, ApkInstaller, InstallStatusReceiver
│ (compiled into both flavors; disabled at runtime when BuildConfig.HAS_UPDATER is false)
├── di/ AppModule (empty, constructor injection), UpdateModule
│ └── update/ UpdateRepository (compiled into both flavors; disabled at runtime when BuildConfig.HAS_UPDATER is false)
├── di/ AppModule (empty, constructor injection)
└── ui/
├── UnraidApp.kt
├── theme/ Color, Density, Dimens, Shapes, Alpha, Type, Theme, ThemeMode (CompositionLocals)
Expand Down Expand Up @@ -232,7 +231,7 @@ mapScalar("URL", "kotlin.String")
mapScalar("Port", "kotlin.Int")
```

JSON-scalar decision rationale (incl. why-not-String): [ADR-0007](docs/adr/0007-apollo-json-scalar-anyadapter.md). In-app updater architecture: [ADR-0008](docs/adr/0008-in-app-updater-packageinstaller.md).
JSON-scalar decision rationale (incl. why-not-String): [ADR-0007](docs/adr/0007-apollo-json-scalar-anyadapter.md). Update notice and browser download: [ADR-0046](docs/adr/0046-update-via-browser-download.md) (the earlier in-app installer, [ADR-0008](docs/adr/0008-in-app-updater-packageinstaller.md), is superseded in part).

### Critical gotchas (don't relearn the hard way)

Expand All @@ -244,7 +243,7 @@ JSON-scalar decision rationale (incl. why-not-String): [ADR-0007](docs/adr/0007-
- **KSP2 + Hilt 2.59.2** (ADR-0020/0023): the old `ksp.useKSP2=false` / Hilt-2.52 pin is gone. Hilt requires Kotlin ≥ 2.1.10 — it is coupled to the toolchain (ADR-0023 rollback note: Hilt can't be reverted in isolation).
- **`DockerContainer.mounts` is `JSON` (single scalar) NOT `[JSON!]`** — the JSON content itself is an encoded array of mount objects. Easy to mistype based on intuition; v0.1.11 made the snapshot fail server-wide because of this exact wrong type. Parse via `parseMountsArray` in `GraphQlMapper.kt`.
- **JSON scalar → kotlin.Any via Apollo's `AnyAdapter`** — the Unraid GraphQL `JSON` scalar serialises as inline JSON values (objects/arrays/primitives), not pre-stringified text. Custom `kotlin.String` adapters crash. We map it to `kotlin.Any` with `com.apollographql.apollo.api.AnyAdapter` (public, built-in). Don't try to write your own with `reader.readAny()` — that's `@ApolloInternal`.
- **In-app updater** lives under `data/update/`: `UpdateRepository` polls `https://api.github.com/repos/hellwand/nova/releases` (installed versions up to 0.1.42 still carry the old `nofuturekid/nova` name and reach it through GitHub's redirect; no auth, 60 req/h rate limit fine for on-start checks), `UpdateInstaller` downloads APK to `cacheDir/updates/` and uses `PackageInstaller` for the install handshake, `InstallStatusReceiver` catches the session callback via a `BroadcastReceiver` declared in the manifest. The `REQUEST_INSTALL_PACKAGES` permission is declared in the `direct` flavor manifest only (`app/src/direct/AndroidManifest.xml`); the updater code itself is compiled into both flavors and disabled at runtime when `BuildConfig.HAS_UPDATER` is false (`store`). On first install Android prompts the user to whitelist us at `Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES`. The Settings screen exposes the "Include pre-releases" toggle + manual "Check now". Banner on MainScreen dismisses per-tag via `dismissedUpdateTag` in DataStore.
- **Update check** lives under `data/update/`: `UpdateRepository` polls `https://api.github.com/repos/hellwand/nova/releases` (installed versions up to 0.1.42 still carry the old `nofuturekid/nova` name and reach it through GitHub's redirect; no auth, 60 req/h rate limit fine for on-start checks), `UpdateDialog` only sends the browser to the release's APK (or the release page when it has no APK) with `ACTION_VIEW`; the app downloads and installs nothing itself and declares no `REQUEST_INSTALL_PACKAGES` in any flavor (ADR-0046). The check is compiled into both flavors and disabled at runtime when `BuildConfig.HAS_UPDATER` is false (`store`). The Settings screen exposes the "Include pre-releases" toggle + manual "Check now". Banner on MainScreen dismisses per-tag via `dismissedUpdateTag` in DataStore.
- Cleartext HTTP requires `android:usesCleartextTraffic="true"` in the manifest. LAN access against `http://192.168.x.x` needs this. The base-config in `network_security_config.xml` permits cleartext; the GitHub hosts (`api.github.com`, `github.com`, `objects.githubusercontent.com`) are pinned to HTTPS.
- **CI `test` job (ADR-0036) can transiently fail at Gradle _configuration_** with a plugin-not-resolvable error (e.g. `com.google.devtools.ksp` not found) on a plugin-portal/registry blip — it fetches plugins fresh under PR `cache-read-only`. **Re-run the job; it is not a code defect** (see ADR-0036 "Known consequence — transient plugin-fetch in the `test` job" note; PR #163 2026-05-19).

Expand Down
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ A few things bend to taste in **Settings**:
## Getting started

1. **Install** NOVA — either from [F-Droid](https://f-droid.org/packages/io.github.nofuturekid.nova/) (stable releases only) or as the latest APK from the [Releases page](https://github.com/hellwand/nova/releases). (For the APK, Android will ask you to allow installing from your browser/files app — that's expected for apps outside the Play Store.)
- Betas are GitHub pre-releases, so they come as an APK from the Releases page. The in-app updater keeps it current when **Include pre-releases** is switched on in Settings; Obtainium works too.
- Betas are GitHub pre-releases, so they come as an APK from the Releases page. NOVA tells you when a new version is out (switch on **Include pre-releases** in Settings for betas) and sends your browser to the APK; Obtainium works too.
- The F-Droid build and the GitHub APK are signed with different keys, so switching between them needs an uninstall first (your saved servers and API keys go with the app). See [ADR-0040](./docs/adr/0040-fdroid-play-distribution-flavors.md).
2. On your Unraid server, generate an **API key**: web UI → _Settings → Management Access → API / Connect_.
3. Open the app → **Add server** and fill in:
Expand All @@ -87,6 +87,22 @@ A few things bend to taste in **Settings**:
- **API Key** — the one you just generated
4. Tap **Test** (next to the local and/or remote connection), then **Save**. The pill in the top bar flips between **Local** and **Remote**.

### Google Play Protect

**Versions up to 0.1.46 (GitHub APK).** On 2026-11-13 we observed that Google Play Protect stopped every GitHub build we tried (0.1.45-beta1, 0.1.45 and 0.1.46-beta1) when it was installed or updated, on a phone with Android 9 and on one with Android 17. Two dialogs appear one after the other:

1. **"App scan recommended"** (German: "App-Scan empfohlen") — Play Protect does not know the file yet. Android shows this for every new file that is not from Google Play. Tap **Scan app** ("App scannen").
2. **"Harmful app blocked"** (German: "Schädliche App blockiert"), "This app may be harmful" ("Diese App könnte schädlich sein."). You can continue through **More details > Install anyway** ("Weitere Details > Trotzdem installieren").

This is Google's rating of the file, not a finding about what NOVA does; Google does not say why it gives it. Our observation, which Google has not confirmed: these builds could install other apps (that is how the old in-app updater worked), and a build of the same version without that ability was rated "This app seems safe" ("Diese App ist anscheinend sicher") by the same scan on the same phone. NOVA is open source (GPL v3), and you can check what you installed:

- **SHA-256 of the file:** the release page lists it next to the APK; compare it with `sha256sum NOVA-v….apk`.
- **Signing certificate:** `apksigner verify --print-certs NOVA-v….apk` should show the SHA-256 digest `9f86baa17c1208004d9284e99597f77276c77301b06340b10075c793152b6824`.

**From the next version on**, NOVA no longer installs updates itself and no longer asks for the permission to install apps. **Download** in the update sheet lets your browser download the file; you open it to install. Android will still ask once to scan each new file ("App scan recommended"), because Play Protect does not know it yet; that is expected. We cannot promise that no warning appears, since Google's rating can change.

**F-Droid** is the channel without any such prompt we have seen, but it has stable releases only, gets updates later, and is signed with a different key. Switching between the GitHub APK and F-Droid means uninstalling first, which removes your saved servers and API keys.

Requires an Unraid 7.x server with the API/Connect plugin enabled. All app features need Unraid API **4.30.0 or newer**; the live network speed needs **4.35.0 or newer** (API versions, not Unraid OS versions). On an older API some screens fail to load.

## Trademark attribution
Expand All @@ -98,7 +114,7 @@ Unraid® is a registered trademark of Lime Technology, Inc. NOVA is an independe
- **Full policy:** <https://github.com/hellwand/nova/blob/main/docs/privacy/POLICY.md>
- **Your API key stays on your device.** It's encrypted at rest using Android Keystore via Google's Tink library, and explicitly excluded from cloud backup / device-transfer.
- **No analytics, no telemetry, no tracking services.** The app talks to your Unraid server's API; in the GitHub (`direct`) build it also asks GitHub's API for app updates (at app start, when you open Settings, or on "Check now"); and it loads container icons from the URLs your own server supplies (often GitHub or a CDN).
- **Update downloads** go directly from GitHub Releases to your device over HTTPS. The downloaded APK is verified by byte-size match and constant-time SHA-256 against the published release asset before any install attempt; mismatch fails closed. See [ADR-0034](./docs/adr/0034-update-and-data-at-rest-hardening.md) for the rationale.
- **Update downloads** are made by your browser, not by NOVA: the update sheet opens the release's APK on GitHub, shows its SHA-256 so you can compare it with the downloaded file, and the app itself downloads and installs nothing. See [ADR-0046](./docs/adr/0046-update-via-browser-download.md).
- **Source is the spec.** Audit, fork, run yourself — GPL v3.

## Contributing & internals
Expand Down
8 changes: 0 additions & 8 deletions app/src/direct/AndroidManifest.xml

This file was deleted.

24 changes: 11 additions & 13 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- REQUEST_INSTALL_PACKAGES is declared in src/direct/AndroidManifest.xml
only (ADR-0040): the `direct` distribution flavor carries the in-app
updater + PackageInstaller pipeline, the `store` flavor does not. -->
<!-- No REQUEST_INSTALL_PACKAGES in any flavor (ADR-0046): the app only
announces updates; the user downloads and installs them through the
browser. Google Play Protect stopped every build that declared this
permission ("Harmful app blocked") and rated the same build without
it "This app seems safe". The remove marker keeps the permission out
even if a library's manifest declares it. -->
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:node="remove" />

<application
android:name=".NovaApp"
Expand Down Expand Up @@ -33,15 +40,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- Catches PackageInstaller session callbacks for the in-app updater. -->
<receiver
android:name=".data.update.InstallStatusReceiver"
android:exported="false">
<intent-filter>
<action android:name="io.github.nofuturekid.nova.action.INSTALL_STATUS" />
</intent-filter>
</receiver>
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,44 @@ data class UpdateInfo(
val version: String, // e.g. "0.1.16" (no leading v)
val tag: String, // e.g. "v0.1.16"
val releaseUrl: String, // browser URL of the GitHub Release page
val downloadUrl: String, // direct APK asset URL
/** The release's APK file; null if the release has no APK attached. */
val apk: ApkAsset? = null,
val isPrerelease: Boolean,
val releaseNotes: String,
/** Release publish time as epoch millis; null if GitHub didn't supply it. */
val publishedAtEpochMs: Long? = null,
) {
/**
* What "Download" opens in the browser: the APK file itself, or the
* release page when the release has no APK attached (ADR-0046). The
* app never downloads or installs the file itself.
*/
val downloadUrl: String get() = apk?.url ?: releaseUrl
}

/** The APK file of a GitHub release, as listed by the Releases API. */
data class ApkAsset(
val url: String, // browser_download_url of the asset
val sizeBytes: Long,
/**
* GitHub asset content digest, e.g. "sha256:<hex>". Null on older
* assets GitHub did not digest (pre-digest releases). When null the
* install path falls back to the size check only — see ADR-0034.
* assets GitHub did not digest (pre-digest releases).
*/
val digest: String? = null,
val isPrerelease: Boolean,
val releaseNotes: String,
/** Release publish time as epoch millis; null if GitHub didn't supply it. */
val publishedAtEpochMs: Long? = null,
)
) {
/**
* The file's SHA-256 as 64 lower-case hex digits, to show next to the
* download so the user can compare it; null if GitHub gave no digest
* or one that is not a SHA-256.
*/
val sha256: String?
get() = digest
?.takeIf { it.startsWith("sha256:", ignoreCase = true) }
?.substring("sha256:".length)
?.trim()
?.lowercase()
?.takeIf { Regex("[0-9a-f]{64}").matches(it) }
}

sealed interface UpdateState {
/** Initial value or while a request is in flight. */
Expand All @@ -29,11 +54,3 @@ sealed interface UpdateState {
/** Check failed (network, parse, rate-limit). */
data class Error(val message: String) : UpdateState
}

sealed interface InstallState {
data object Idle : InstallState
data class Downloading(val progress: Float) : InstallState
data object Installing : InstallState
data class NeedsPermission(val intent: android.content.Intent) : InstallState
data class Failed(val message: String) : InstallState
}
Loading
Loading