From b074bf6aaa1ad69ab343ef0854394bb32d74951f Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Sat, 8 Aug 2026 14:58:11 +0200 Subject: [PATCH] Add Codeberg releases source --- README.md | 5 +- config.example.toml | 17 +++++++ pyproject.toml | 2 +- src/sideload.py | 73 +++++++++++++++++++++++++++-- tests/test_sideload.py | 102 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fa5fbb9..bc74a41 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # adb-sideload-free-apps -Install free Android apps via ADB from F-Droid, GitHub Releases, or GitLab Releases. +Install free Android apps via ADB from F-Droid, GitHub Releases, GitLab Releases, or Codeberg Releases. ## Requirements @@ -38,8 +38,9 @@ See `config.example.toml`. Supported sources: | `fdroid` | `url` (page F-Droid) | | `github` | `url` (repository) | | `gitlab` | `url` (projet) | +| `codeberg` | `url` (repository Codeberg) | -Every application also requires `name` and `package`. For GitHub and GitLab, +Every application also requires `name` and `package`. For GitHub, GitLab, and Codeberg, set `asset_pattern` when a release contains more than one suitable APK. Debug, test and unsigned APKs are rejected by default. diff --git a/config.example.toml b/config.example.toml index c8b8998..6108538 100644 --- a/config.example.toml +++ b/config.example.toml @@ -38,3 +38,20 @@ url = "https://f-droid.org/packages/org.mozilla.fennec_fdroid/" # source = "gitlab" # package = "com.aurora.store" # url = "https://gitlab.com/AuroraOSS/AuroraStore" + +# --- Codeberg Releases --- +# The URL points to the Codeberg repository. + +# [[apps]] +# name = "CoMaps" +# source = "codeberg" +# package = "app.comaps" +# url = "https://codeberg.org/comaps/comaps" +# asset_pattern = "^CoMaps-.*-main-release\\.apk$" + +# [[apps]] +# name = "Fedilab" +# source = "codeberg" +# package = "app.fedilab.android" +# url = "https://codeberg.org/tom79/Fedilab" +# asset_pattern = "^Fedilab-Google-.*\\.apk$" diff --git a/pyproject.toml b/pyproject.toml index 0947071..81c424f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "adb-sideload-free-apps" version = "0.1.0" -description = "Install free Android apps via ADB from F-Droid, GitHub, or GitLab" +description = "Install free Android apps via ADB from F-Droid, GitHub, GitLab, or Codeberg" requires-python = ">=3.11" license = { text = "MIT" } readme = "README.md" diff --git a/src/sideload.py b/src/sideload.py index d83c0ec..157a8c0 100644 --- a/src/sideload.py +++ b/src/sideload.py @@ -1,4 +1,4 @@ -"""ADB Sideload Free Apps — install free Android apps from F-Droid, GitHub, GitLab via ADB.""" +"""ADB Sideload Free Apps — install free Android apps from F-Droid, GitHub, GitLab, or Codeberg via ADB.""" from __future__ import annotations @@ -412,6 +412,72 @@ class GitLabSource: return cache.store(release.app_entry.app_id, dest, sha) +# ── Codeberg source ─────────────────────────────────────────────────────────── + + +class CodebergSource: + API = "https://codeberg.org/api/v1" + + def __init__(self, app_entry: AppEntry): + self.app_entry = app_entry + self.repo_url = app_entry.config.get("url", "") + parsed = urllib.parse.urlsplit(self.repo_url) + parts = parsed.path.strip("/").removesuffix(".git").split("/") + if ( + parsed.scheme != "https" + or parsed.netloc != "codeberg.org" + or parsed.query + or parsed.fragment + or len(parts) != 2 + or not all(parts) + ): + raise ValueError("url must be an HTTPS Codeberg repository URL") + self.owner, self.repo = parts + + def get_latest_release(self) -> ReleaseInfo: + url = f"{self.API}/repos/{self.owner}/{self.repo}/releases/latest" + release = json.loads(_http_get(url)) + if not release or release.get("draft") or release.get("prerelease"): + raise ValueError(f"no stable release for {self.owner}/{self.repo}") + version = (release.get("tag_name") or release.get("name") or "unknown").lstrip("v") + pattern = self.app_entry.config.get("asset_pattern", r"(?i).*\.apk$") + regex = re.compile(pattern) + apk_assets = [ + asset + for asset in release.get("assets", []) + if asset.get("name", "").lower().endswith(".apk") + and regex.fullmatch(asset.get("name", "")) + and not re.search(r"(?i)(debug|test|unsigned)", asset.get("name", "")) + ] + if not apk_assets: + raise ValueError( + f"no APK asset in latest release of {self.owner}/{self.repo}" + ) + if len(apk_assets) > 1: + raise ValueError( + "multiple APK assets match; set asset_pattern more precisely" + ) + asset = apk_assets[0] + return ReleaseInfo( + app_entry=self.app_entry, + version=version, + download_url=asset["browser_download_url"], + expected_sha256=self.app_entry.config.get("sha256"), + file_size=asset.get("size"), + ) + + def download_apk(self, release: ReleaseInfo, cache: DownloadCache) -> Path: + stem = _make_apk_stem(release) + dest = cache.root / release.app_entry.app_id / f"{stem}.apk" + if not dest.is_file(): + dest = _atomic_download(release.download_url, dest) + sha = _sha256_file(dest) + if release.expected_sha256 and sha.lower() != release.expected_sha256.lower(): + dest.unlink(missing_ok=True) + raise ValueError(f"SHA-256 mismatch for {release.app_entry.name}") + return cache.store(release.app_entry.app_id, dest, sha) + + # ── APK validation ──────────────────────────────────────────────────────────── @@ -545,14 +611,15 @@ SOURCE_REGISTRY = { "fdroid": FdroidSource, "github": GitHubSource, "gitlab": GitLabSource, + "codeberg": CodebergSource, } -def _make_source(app: AppEntry) -> FdroidSource | GitHubSource | GitLabSource: +def _make_source(app: AppEntry) -> FdroidSource | GitHubSource | GitLabSource | CodebergSource: cls = SOURCE_REGISTRY.get(app.source) if cls is None: raise ValueError( - f"unknown source type '{app.source}'. Use fdroid, github, or gitlab" + f"unknown source type '{app.source}'. Use fdroid, github, gitlab, or codeberg" ) return cls(app) diff --git a/tests/test_sideload.py b/tests/test_sideload.py index c621d72..8e627a3 100644 --- a/tests/test_sideload.py +++ b/tests/test_sideload.py @@ -120,6 +120,19 @@ def gitlab_entry() -> sideload.AppEntry: ) +@pytest.fixture +def codeberg_entry() -> sideload.AppEntry: + return sideload.AppEntry( + name="CoMaps", + source="codeberg", + app_id="app.comaps", + config={ + "url": "https://codeberg.org/comaps/comaps", + "asset_pattern": r"^CoMaps-.*-main-release\.apk$", + }, + ) + + @pytest.fixture def config(tmp_config: Path) -> sideload.Config: return sideload.Config.from_file(tmp_config) @@ -428,6 +441,91 @@ class TestGitHubSource: assert result.read_bytes() == apk_data +# ── CodebergSource tests ────────────────────────────────────────────────────── + + +class TestCodebergSource: + CODEBERG_RELEASE = { + "tag_name": "v2026.08.0", + "assets": [ + { + "name": "CoMaps-2026.08.0-main-release.apk", + "browser_download_url": "https://codeberg.org/comaps/comaps/releases/download/v2026.08.0/CoMaps-2026.08.0-main-release.apk", + "size": 12345678, + }, + { + "name": "CoMaps-2026.08.0-debug.apk", + "browser_download_url": "https://codeberg.org/comaps/comaps/releases/download/v2026.08.0/CoMaps-2026.08.0-debug.apk", + }, + { + "name": "CoMaps-2026.08.0-test.apk", + "browser_download_url": "https://codeberg.org/comaps/comaps/releases/download/v2026.08.0/CoMaps-2026.08.0-test.apk", + }, + { + "name": "CoMaps-2026.08.0-unsigned.apk", + "browser_download_url": "https://codeberg.org/comaps/comaps/releases/download/v2026.08.0/CoMaps-2026.08.0-unsigned.apk", + }, + { + "name": "Source code (zip)", + "browser_download_url": "https://codeberg.org/comaps/comaps/archive/v2026.08.0.zip", + }, + ], + } + + def test_get_latest_release(self, codeberg_entry: sideload.AppEntry): + source = sideload.CodebergSource(codeberg_entry) + with mock.patch("sideload._http_get", return_value=json.dumps(self.CODEBERG_RELEASE).encode()) as http_get: + release = source.get_latest_release() + http_get.assert_called_once_with( + "https://codeberg.org/api/v1/repos/comaps/comaps/releases/latest" + ) + assert release.version == "2026.08.0" + assert release.download_url.endswith("CoMaps-2026.08.0-main-release.apk") + assert release.file_size == 12345678 + + def test_get_latest_release_ambiguous_assets(self, codeberg_entry: sideload.AppEntry): + source = sideload.CodebergSource(codeberg_entry) + release = { + "tag_name": "v1.0", + "assets": [ + {"name": "CoMaps-one-main-release.apk", "browser_download_url": "https://example.com/one.apk"}, + {"name": "CoMaps-two-main-release.apk", "browser_download_url": "https://example.com/two.apk"}, + ], + } + with mock.patch("sideload._http_get", return_value=json.dumps(release).encode()): + with pytest.raises(ValueError, match="multiple APK assets match"): + source.get_latest_release() + + def test_get_latest_release_no_apk(self, codeberg_entry: sideload.AppEntry): + source = sideload.CodebergSource(codeberg_entry) + release = {"tag_name": "v1.0", "assets": [{"name": "Source code (tar.gz)"}]} + with mock.patch("sideload._http_get", return_value=json.dumps(release).encode()): + with pytest.raises(ValueError, match="no APK asset"): + source.get_latest_release() + + @pytest.mark.parametrize( + "url", + [ + "http://codeberg.org/comaps/comaps", + "https://example.com/comaps/comaps", + "https://codeberg.org/comaps", + "https://codeberg.org/comaps/comaps/releases", + ], + ) + def test_invalid_url_raises(self, url: str): + entry = sideload.AppEntry( + name="CoMaps", source="codeberg", app_id="app.comaps", config={"url": url} + ) + with pytest.raises(ValueError, match="HTTPS Codeberg repository URL"): + sideload.CodebergSource(entry) + + def test_get_latest_release_network_error(self, codeberg_entry: sideload.AppEntry): + source = sideload.CodebergSource(codeberg_entry) + with mock.patch("sideload._http_get", side_effect=RuntimeError("network down")): + with pytest.raises(RuntimeError, match="network down"): + source.get_latest_release() + + # ── GitLabSource tests ──────────────────────────────────────────────────────── @@ -645,6 +743,10 @@ class TestMakeSource: s = sideload._make_source(gitlab_entry) assert isinstance(s, sideload.GitLabSource) + def test_codeberg(self, codeberg_entry: sideload.AppEntry): + s = sideload._make_source(codeberg_entry) + assert isinstance(s, sideload.CodebergSource) + def test_unknown_source_raises(self): entry = sideload.AppEntry(name="X", source="unknown", app_id="x", config={}) with pytest.raises(ValueError, match="unknown source type"):