feat: a newer app is told in a snackbar on Home and installed from a sheet holding Settings' card, fetched ahead on Wi-Fi (#6452)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m55s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / integration (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m55s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / integration (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
Mirrors Librarian (#6446, #6448), after Gallery's update tray (canon #6367). - Home shows "Version X of the app is on your server." / "…is downloaded and ready." with Update / Install; the button opens a bottom sheet holding the same About card Settings shows, so there is one install path. Each build is told at most once a day, kept on disk. - The shell-wide update banner and its in-memory dismissals are gone. - AppUpdates (singleton) holds the offer and the install, so Home and Settings show the same state. Checks run when either screen opens, on every return to the front, every 5 minutes while shown, and on Home's pull to refresh. - UpdateFetchWorker downloads the newer APK ahead on an unmetered network (battery and storage not low, three tries); the download writes a .part file and keeps it under the build's name only once its size and sha256 match, so Install is usually one tap. - Back from allowing installs, the update the listener asked for carries on (kept in SavedStateHandle) instead of asking them to tap again. - An offer needs a higher code AND a different name: the same app built again at a later minute is not offered. - Server: /api/client/version reports the APK's sha256, worked out from the served file and cached by size and mtime. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,8 +17,11 @@ package api
|
||||
// client treats 404 as "no update channel available."
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -62,6 +65,41 @@ var (
|
||||
clientAPKLastDownloadMu sync.Mutex
|
||||
)
|
||||
|
||||
// clientAPKSum is the bundled APK's sha256, kept until the file changes
|
||||
// under it (another size or modification time), so a version check every
|
||||
// few minutes from every phone does not re-read tens of megabytes each time.
|
||||
type clientAPKSum struct {
|
||||
size int64
|
||||
mod time.Time
|
||||
hex string
|
||||
}
|
||||
|
||||
var (
|
||||
clientAPKSumCache clientAPKSum
|
||||
clientAPKSumCacheMu sync.Mutex
|
||||
)
|
||||
|
||||
// clientAPKDigest returns the sha256 of the APK at path, in hex.
|
||||
func clientAPKDigest(path string, st os.FileInfo) (string, error) {
|
||||
clientAPKSumCacheMu.Lock()
|
||||
defer clientAPKSumCacheMu.Unlock()
|
||||
c := clientAPKSumCache
|
||||
if c.hex != "" && c.size == st.Size() && c.mod.Equal(st.ModTime()) {
|
||||
return c.hex, nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
clientAPKSumCache = clientAPKSum{size: st.Size(), mod: st.ModTime(), hex: hex.EncodeToString(h.Sum(nil))}
|
||||
return clientAPKSumCache.hex, nil
|
||||
}
|
||||
|
||||
func testResetClientAPKRateLimit() {
|
||||
clientAPKLastDownloadMu.Lock()
|
||||
defer clientAPKLastDownloadMu.Unlock()
|
||||
@@ -115,6 +153,11 @@ type clientVersionResponse struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
APKURL string `json:"apk_url"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
// SHA256 is worked out from the file being served, not copied from the
|
||||
// sidecar, so it cannot disagree with the bytes the phone receives. The
|
||||
// app downloads ahead on Wi-Fi and installs later; this is how it knows
|
||||
// the file it kept is the whole build that was offered.
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// handleClientVersion returns the bundled Android client version + a
|
||||
@@ -159,12 +202,19 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sum, err := clientAPKDigest(apkPath, stat)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "client_version: digest apk", err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, clientVersionResponse{
|
||||
Version: strings.TrimSpace(sidecar.Name),
|
||||
Code: sidecar.Code,
|
||||
Channel: strings.TrimSpace(sidecar.Channel),
|
||||
APKURL: "/api/client/apk",
|
||||
SizeBytes: stat.Size(),
|
||||
SHA256: sum,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -128,6 +130,39 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
|
||||
if resp.SizeBytes != size {
|
||||
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
|
||||
}
|
||||
want := sha256.Sum256([]byte("fake apk content"))
|
||||
if resp.SHA256 != hex.EncodeToString(want[:]) {
|
||||
t.Errorf("sha256: want the served file's digest %x, got %q", want, resp.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
// The digest is cached by size and modification time; a new APK dropped in
|
||||
// place must be answered with its own digest, not the old one's.
|
||||
func TestClientVersion_DigestFollowsAReplacedAPK(t *testing.T) {
|
||||
writeClientAssets(t, `{"name":"a","code":1}`)
|
||||
first := decodeClientVersion(t, getClientVersion(t))
|
||||
dir := os.Getenv("MINSTREL_CLIENT_APK_DIR")
|
||||
apk := filepath.Join(dir, clientAPKFilename)
|
||||
if err := os.WriteFile(apk, []byte("another, longer build"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := decodeClientVersion(t, getClientVersion(t))
|
||||
want := sha256.Sum256([]byte("another, longer build"))
|
||||
if second.SHA256 != hex.EncodeToString(want[:]) || second.SHA256 == first.SHA256 {
|
||||
t.Errorf("sha256 after replace: want %x, got %q (before: %q)", want, second.SHA256, first.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeClientVersion(t *testing.T, rr *httptest.ResponseRecorder) clientVersionResponse {
|
||||
t.Helper()
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp clientVersionResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// A release published before ordering keys were recorded has a name and
|
||||
|
||||
Reference in New Issue
Block a user