package api // In-app update endpoints (#397). The Android APK ships bundled with // the server image so the client can self-update without an external // app store. CI sequencing bakes the APK + sidecar version file into // /app/client/ at image build time. // // Both endpoints are unauthenticated — install flow shouldn't depend // on a live session, and the URL is hard to guess at scale. // // Returns 404 gracefully when the APK isn't present (dev environments, // pre-CI-wiring); the Flutter client treats 404 as "no update channel // available" and falls back to the manual download. import ( "errors" "net/http" "os" "path/filepath" "strings" ) const ( defaultClientAPKDir = "/app/client" clientAPKFilename = "minstrel.apk" clientVersionFile = "minstrel.apk.version" ) // clientAPKDir resolves the directory holding the bundled APK. Env // var MINSTREL_CLIENT_APK_DIR overrides for dev; default matches the // Dockerfile's COPY destination. func clientAPKDir() string { if d, ok := os.LookupEnv("MINSTREL_CLIENT_APK_DIR"); ok && d != "" { return d } return defaultClientAPKDir } type clientVersionResponse struct { Version string `json:"version"` APKURL string `json:"apk_url"` SizeBytes int64 `json:"size_bytes"` } // handleClientVersion returns the bundled Android client version + a // URL to fetch the APK. 404 when no APK is bundled. func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) { dir := clientAPKDir() apkPath := filepath.Join(dir, clientAPKFilename) versionPath := filepath.Join(dir, clientVersionFile) stat, err := os.Stat(apkPath) if errors.Is(err, os.ErrNotExist) { http.Error(w, `{"error":{"code":"no_client_apk","message":"no bundled client apk"}}`, http.StatusNotFound) return } if err != nil { writeErrWithLog(w, h.logger, "client_version: stat apk", err) return } versionBytes, err := os.ReadFile(versionPath) if errors.Is(err, os.ErrNotExist) { http.Error(w, `{"error":{"code":"no_client_version","message":"apk present but version file missing"}}`, http.StatusNotFound) return } if err != nil { writeErrWithLog(w, h.logger, "client_version: read version", err) return } writeJSON(w, http.StatusOK, clientVersionResponse{ Version: strings.TrimSpace(string(versionBytes)), APKURL: "/api/client/apk", SizeBytes: stat.Size(), }) } // handleClientAPK streams the bundled APK with the correct // Content-Type so Android's PackageInstaller accepts it. func (h *handlers) handleClientAPK(w http.ResponseWriter, r *http.Request) { apkPath := filepath.Join(clientAPKDir(), clientAPKFilename) f, err := os.Open(apkPath) if errors.Is(err, os.ErrNotExist) { http.Error(w, `{"error":{"code":"no_client_apk","message":"no bundled client apk"}}`, http.StatusNotFound) return } if err != nil { writeErrWithLog(w, h.logger, "client_apk: open", err) return } defer f.Close() stat, err := f.Stat() if err != nil { writeErrWithLog(w, h.logger, "client_apk: stat", err) return } // Use http.ServeContent so Range requests work — install flows on // flaky networks may resume rather than restart. w.Header().Set("Content-Type", "application/vnd.android.package-archive") w.Header().Set("Content-Disposition", `attachment; filename="minstrel.apk"`) http.ServeContent(w, r, clientAPKFilename, stat.ModTime(), f) }