Add Codeberg releases source
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# adb-sideload-free-apps
|
# 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
|
## Requirements
|
||||||
|
|
||||||
@@ -38,8 +38,9 @@ See `config.example.toml`. Supported sources:
|
|||||||
| `fdroid` | `url` (page F-Droid) |
|
| `fdroid` | `url` (page F-Droid) |
|
||||||
| `github` | `url` (repository) |
|
| `github` | `url` (repository) |
|
||||||
| `gitlab` | `url` (projet) |
|
| `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,
|
set `asset_pattern` when a release contains more than one suitable APK. Debug,
|
||||||
test and unsigned APKs are rejected by default.
|
test and unsigned APKs are rejected by default.
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,20 @@ url = "https://f-droid.org/packages/org.mozilla.fennec_fdroid/"
|
|||||||
# source = "gitlab"
|
# source = "gitlab"
|
||||||
# package = "com.aurora.store"
|
# package = "com.aurora.store"
|
||||||
# url = "https://gitlab.com/AuroraOSS/AuroraStore"
|
# 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]
|
[project]
|
||||||
name = "adb-sideload-free-apps"
|
name = "adb-sideload-free-apps"
|
||||||
version = "0.1.0"
|
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"
|
requires-python = ">=3.11"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
readme = "README.md"
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -412,6 +412,72 @@ class GitLabSource:
|
|||||||
return cache.store(release.app_entry.app_id, dest, sha)
|
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 ────────────────────────────────────────────────────────────
|
# ── APK validation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -545,14 +611,15 @@ SOURCE_REGISTRY = {
|
|||||||
"fdroid": FdroidSource,
|
"fdroid": FdroidSource,
|
||||||
"github": GitHubSource,
|
"github": GitHubSource,
|
||||||
"gitlab": GitLabSource,
|
"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)
|
cls = SOURCE_REGISTRY.get(app.source)
|
||||||
if cls is None:
|
if cls is None:
|
||||||
raise ValueError(
|
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)
|
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
|
@pytest.fixture
|
||||||
def config(tmp_config: Path) -> sideload.Config:
|
def config(tmp_config: Path) -> sideload.Config:
|
||||||
return sideload.Config.from_file(tmp_config)
|
return sideload.Config.from_file(tmp_config)
|
||||||
@@ -428,6 +441,91 @@ class TestGitHubSource:
|
|||||||
assert result.read_bytes() == apk_data
|
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 ────────────────────────────────────────────────────────
|
# ── GitLabSource tests ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -645,6 +743,10 @@ class TestMakeSource:
|
|||||||
s = sideload._make_source(gitlab_entry)
|
s = sideload._make_source(gitlab_entry)
|
||||||
assert isinstance(s, sideload.GitLabSource)
|
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):
|
def test_unknown_source_raises(self):
|
||||||
entry = sideload.AppEntry(name="X", source="unknown", app_id="x", config={})
|
entry = sideload.AppEntry(name="X", source="unknown", app_id="x", config={})
|
||||||
with pytest.raises(ValueError, match="unknown source type"):
|
with pytest.raises(ValueError, match="unknown source type"):
|
||||||
|
|||||||
Reference in New Issue
Block a user