Add Codeberg releases source

This commit is contained in:
2026-08-08 14:58:11 +02:00
parent ae4a95a337
commit b074bf6aaa
5 changed files with 193 additions and 6 deletions

View File

@@ -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.

View File

@@ -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$"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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"):