Add Codeberg releases source
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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$"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user