Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .github/scripts/generate_update_manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Create the direct-update manifest from already-verified release inputs. No publication."""

from __future__ import annotations

import argparse
import hashlib
import html
import json
import re
from pathlib import Path

from prepare_release import read_app_version
import update_changelog


def listener_markdown(fragment: str) -> str:
"""Preserve authored emphasis and links from the generated listener HTML."""
fragment = re.sub(r'<a\b[^>]*href=[\'"]([^\'"]+)[\'"][^>]*>(.*?)</a>',
lambda m: '[' + re.sub(r'<[^>]+>', '', m[2]) + '](' + html.unescape(m[1]) + ')',
fragment, flags=re.DOTALL | re.IGNORECASE)
for tag, marker in (("b", "**"), ("strong", "**"), ("i", "*"), ("em", "*"), ("code", "`")):
fragment = re.sub(r'</?' + tag + r'\b[^>]*>', lambda _: marker, fragment, flags=re.IGNORECASE)
fragment = re.sub(r'<br\s*/?>', '\n', fragment, flags=re.IGNORECASE)
return html.unescape(re.sub(r'<[^>]+>', '', fragment)).strip()


def listener_notes(readme: str, tag: str | None = None) -> str:
region = update_changelog._extract_marked_region(
readme, update_changelog.RELEASE_WHATS_NEW_START,
update_changelog.RELEASE_WHATS_NEW_END,
)
if not region:
return ""
metadata = update_changelog._parse_release_meta(region)
if tag and metadata and metadata[0] != tag:
return "" # An artifacts-only release must not claim the previous release's notes.
# Preserve every authored listener bullet, without shortening or rewriting.
return "\n".join("- " + listener_markdown(bullet) for bullet in re.findall(
r"<li\b[^>]*>(.*?)</li>", region, flags=re.DOTALL,
))


def upcoming_notes(readme: str) -> str:
region = update_changelog._extract_marked_region(
readme, update_changelog.RELEASE_UPCOMING_START, update_changelog.RELEASE_UPCOMING_END,
) or ""
parts = []
for match in re.finditer(r'<b\b[^>]*>(.*?)</b>|<li\b[^>]*>(.*?)</li>', region, flags=re.DOTALL):
if match[1] is not None:
parts.append("\n## " + listener_markdown(match[1]).rstrip(":"))
else:
parts.append("- " + listener_markdown(match[2]))
value = "\n".join(parts).strip() if "<li" in region else ""
if len(value.encode("utf-16-le")) // 2 > 6_000:
raise ValueError("Upcoming preview exceeds 6,000 characters")
return value


def build_manifest(gradle: str, apk: Path, asset: str, notes: str = "") -> dict:
version = read_app_version(gradle)
if not re.fullmatch(r"[A-Za-z0-9_.-]+\.apk", asset):
raise ValueError("APK asset must be a plain .apk filename")
sdk = re.search(r"\bminSdk\s*=\s*(\d+)", gradle)
if sdk is None or int(sdk.group(1)) < 31:
raise ValueError("Could not resolve supported minimum Android version")
size = apk.stat().st_size
if not 0 < size <= 512 * 1024 * 1024:
raise ValueError("APK is missing, empty or larger than the updater limit")
if len(notes.encode("utf-16-le")) // 2 > 24_000:
raise ValueError("Release notes exceed 24,000 characters; supply concise reviewed copy")
with apk.open("rb") as binary:
digest = hashlib.file_digest(binary, "sha256").hexdigest()
base = "https://github.com/boxcreate/boxlore/releases"
manifest = {
"schemaVersion": 1, "packageName": "cx.aswin.boxlore",
"versionCode": version.code, "versionName": version.name,
"minSdk": int(sdk.group(1)), "apkUrl": f"{base}/download/{version.tag}/{asset}",
"apkSha256": digest, "apkBytes": size, "notesUrl": f"{base}/tag/{version.tag}",
"notes": notes,
}
if len(encode_manifest(manifest)) > 64 * 1024:
raise ValueError("Update manifest exceeds 64 KiB; shorten the reviewed release notes")
return manifest


def encode_manifest(manifest: dict) -> bytes:
return (json.dumps(manifest, indent=2, ensure_ascii=False) + "\n").encode("utf-8")


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--apk", required=True, type=Path)
parser.add_argument("--asset", required=True)
parser.add_argument("--gradle", type=Path, default=Path("app/build.gradle.kts"))
parser.add_argument("--readme", type=Path, default=Path("README.md"))
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
try:
gradle = args.gradle.read_text()
manifest = build_manifest(gradle, args.apk, args.asset,
listener_notes(args.readme.read_text(), read_app_version(gradle).tag))
manifest["upcoming"] = upcoming_notes(args.readme.read_text())
encoded = encode_manifest(manifest)
if len(encoded) > 64 * 1024:
raise ValueError("Update manifest exceeds 64 KiB with upcoming preview")
args.output.write_bytes(encoded)
except (OSError, ValueError) as error:
parser.exit(1, f"Update manifest not prepared: {error}. Fix the candidate and rerun publication; no update announcement was sent.\n")


if __name__ == "__main__":
main()
227 changes: 158 additions & 69 deletions .github/scripts/manual_dispatch.py
Original file line number Diff line number Diff line change
@@ -1,77 +1,166 @@
"""Preview, validate or send a legacy-compatible manual announcement.

Preview mode has no Firebase dependency. Test mode only accepts debug_users or test_users;
it never silently redirects a request intended for a production audience.
"""

import argparse
import os
import hashlib
import json
import firebase_admin
from firebase_admin import credentials
from firebase_admin import messaging
import os
import sys
from pathlib import Path
from urllib.parse import urlparse

def send_notification(args):
creds_json = os.environ.get('FIREBASE_CREDENTIALS')
if not creds_json:
raise ValueError("FIREBASE_CREDENTIALS environment variable is not set")

cred_dict = json.loads(creds_json)
cred = credentials.Certificate(cred_dict)
firebase_admin.initialize_app(cred)

decoded_body = args.body.replace('\\n', '\n')

data_payload = {
'title': args.title,
'body': decoded_body,
'type': args.type,
'sound': getattr(args, 'sound', 'default'),
'action_label': getattr(args, 'action_label', 'View'),
'show_action_in_push': getattr(args, 'show_action_in_push', 'true'),
'show_action_in_app': getattr(args, 'show_action_in_app', 'true'),
'category': getattr(args, 'category', "WHAT'S NEW")
TOPIC_PAYLOAD_LIMIT = 2048 # FCM counts UTF-8 data keys and values for topics.
AUDIENCES = ("all_users", "debug_users", "prod_users", "test_users", "direct_users")


def boolean(value, field):
if value not in ("true", "false"):
raise ValueError(f"{field} must be true or false")
return value == "true"


def notification_plan(args):
"""Pure validation and payload construction, shared by previews and sends."""
topic = getattr(args, "target", "all_users")
if topic not in AUDIENCES:
raise ValueError("Choose a supported announcement audience")
test_mode = boolean(getattr(args, "test_mode", "false"), "test_mode")
if test_mode and topic not in ("debug_users", "test_users"):
raise ValueError("Test mode only permits debug_users or test_users. Change the target before retrying.")
if args.type not in ("push", "in-app", "both"):
raise ValueError("Message type must be push, in-app or both")
if not args.title.strip() or not args.body.strip():
raise ValueError("Add a title and message before previewing or sending")
if getattr(args, "image", ""):
image = urlparse(args.image)
if image.scheme != "https" or not image.netloc or image.username or image.password:
raise ValueError("Use an HTTPS image URL without embedded credentials")
sound = getattr(args, "sound", "default")
if sound not in ("default", "chime", "announcement", "silent"):
raise ValueError("Choose a supported notification sound")
data = {
"title": args.title,
"body": args.body.replace("\\n", "\n"),
"type": args.type,
"sound": sound,
"action_label": getattr(args, "action_label", "View"),
"show_action_in_push": getattr(args, "show_action_in_push", "true"),
"show_action_in_app": getattr(args, "show_action_in_app", "true"),
"category": getattr(args, "category", "ANNOUNCEMENT"),
}

if args.route:
data_payload['route'] = args.route

if getattr(args, 'image', None):
data_payload['image'] = args.image

topic = getattr(args, 'target', 'all_users')

android_config = None
collapse_key = getattr(args, 'collapse_key', '')
if collapse_key:
android_config = messaging.AndroidConfig(collapse_key=collapse_key)

message = messaging.Message(
data=data_payload,
topic=topic,
android=android_config
for field in ("show_action_in_push", "show_action_in_app"):
boolean(data[field], field)
if getattr(args, "release_url", ""):
url = urlparse(args.release_url)
if url.scheme != "https" or url.netloc != "github.com" or url.query or url.fragment or not (url.path == "/boxcreate/boxlore/releases/latest" or url.path.startswith("/boxcreate/boxlore/releases/tag/")):
raise ValueError("Use a boxlore GitHub release page")
for field in ("route", "image"):
if getattr(args, field, ""):
data[field] = getattr(args, field)
for field, choices, default in (
("presentation", ("compact", "fullscreen"), "compact"),
("tone", ("primary", "secondary", "tertiary", "error"), "primary"),
("image_style", ("banner", "cover"), "banner"),
):
value = getattr(args, field, default)
if value not in choices: raise ValueError(f"Choose a supported {field}")
data[field] = value
for field in ("release_alert", "include_play"):
data[field] = str(boolean(getattr(args, field, "false"), field)).lower()
if data["release_alert"] == "false" and getattr(args, "release_url", ""):
raise ValueError("Enable Release alert to use a GitHub release page, or clear the release URL")
if data["release_alert"] == "true" and getattr(args, "release_url", ""):
data["release_url"] = args.release_url
Comment thread
ashwkun marked this conversation as resolved.
data["test_mode"] = str(test_mode).lower()
code = getattr(args, "release_version_code", "0")
if not str(code).isdigit() or int(code) > 2147483647: raise ValueError("Release version code must be a nonnegative whole number")
data["release_version_code"] = str(int(code))
if topic == "direct_users" and data["include_play"] == "true":
raise ValueError("Choose all_users to include Google Play installs")
if data["release_alert"] == "true" and topic not in ("direct_users", "debug_users", "test_users") and data["include_play"] != "true":
raise ValueError("Release alerts must target direct_users unless Google Play is explicitly included")
size = sum(len(key.encode("utf-8")) + len(value.encode("utf-8")) for key, value in data.items())
if size > TOPIC_PAYLOAD_LIMIT:
raise ValueError(
f"Message data uses {size} bytes; topic messages allow {TOPIC_PAYLOAD_LIMIT}. "
"Shorten the message or link to the full notes, then preview again."
)
return {
"topic": topic,
"data": data,
"collapse_key": getattr(args, "collapse_key", ""),
"dry_run": boolean(getattr(args, "dry_run", "false"), "dry_run"),
"test_mode": test_mode,
"payload_bytes": size,
}


def plan_digest(plan):
return hashlib.sha256(json.dumps(plan, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()


def _firebase_send(plan):
# Only the sending path imports/initializes Firebase. Offline previews cannot send.
import firebase_admin
from firebase_admin import credentials, messaging

creds_json = os.environ.get("FIREBASE_CREDENTIALS")
if not creds_json:
raise ValueError("FIREBASE_CREDENTIALS is required for provider validation or sending")
firebase_admin.initialize_app(credentials.Certificate(json.loads(creds_json)))
android = messaging.AndroidConfig(collapse_key=plan["collapse_key"]) if plan["collapse_key"] else None
audience = {"condition": "'direct_users' in topics && !('play_users' in topics) && !('test_users' in topics)"} if plan["topic"] == "direct_users" else {"topic": plan["topic"]}
return messaging.send(
messaging.Message(data=plan["data"], android=android, **audience),
dry_run=plan["dry_run"],
)

dry_run_bool = (getattr(args, 'dry_run', 'false') == 'true')
try:
response = messaging.send(message, dry_run=dry_run_bool)
if dry_run_bool:
print(f"Dry run validation succeeded for topic '{topic}': {response}")


def send_notification(args, *, sender=None):
plan = notification_plan(args)
if boolean(getattr(args, "preview_only", "false"), "preview_only"):
preview = {**plan, "approval_digest": plan_digest(plan), "mode": "offline-preview", "sent": False}
rendered = json.dumps(preview, ensure_ascii=False, indent=2) + "\n"
output = getattr(args, "preview_output", "")
if output:
Path(output).write_text(rendered, encoding="utf-8")
else:
print(f"Successfully sent message to topic '{topic}': {response}")
except Exception as e:
print(f"Failed to send message: {e}")
raise e
print(rendered, end="")
return preview
if not plan["dry_run"] and getattr(args, "approval_digest", "") != plan_digest(plan):
raise ValueError("Review the current preview and approve it before sending. Edited drafts need a new approval.")
response = (sender or _firebase_send)(plan)
if plan["dry_run"]:
print(f"Provider validation passed for '{plan['topic']}'. No message sent. ID: {response}")
else:
print(f"FCM accepted the message for '{plan['topic']}'. Device delivery is not confirmed. ID: {response}")
return {**plan, "mode": "provider-validation" if plan["dry_run"] else "send", "response": response}


def argument_parser():
parser = argparse.ArgumentParser(description="Preview, validate or send an FCM announcement")
parser.add_argument("--title", required=True)
parser.add_argument("--body", required=True)
parser.add_argument("--type", required=True)
for name, default in (
("route", ""), ("image", ""), ("target", "all_users"),
("dry-run", "false"), ("test-mode", "false"), ("preview-only", "false"),
("preview-output", ""), ("collapse-key", ""), ("sound", "default"),
("presentation", "compact"), ("tone", "primary"), ("image-style", "banner"),
("release-url", ""), ("release-alert", "false"), ("include-play", "false"), ("release-version-code", "0"), ("approval-digest", ""),
("action-label", "View"), ("show-action-in-push", "true"),
("show-action-in-app", "true"), ("category", "ANNOUNCEMENT"),
):
parser.add_argument("--" + name, default=default)
return parser


if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Send FCM Notification')
parser.add_argument('--title', required=True, help='Notification Title')
parser.add_argument('--body', required=True, help='Notification Body')
parser.add_argument('--type', required=True, help='Where to show (push/in-app/both)')
parser.add_argument('--route', required=False, default="", help='Optional Deep Link Route')
parser.add_argument('--image', required=False, default="", help='Optional Big Image URL')
parser.add_argument('--target', required=False, default="all_users", help='Target audience topic')
parser.add_argument('--dry-run', required=False, default="false", help='Validate only')
parser.add_argument('--collapse-key', required=False, default="", help='Optional Android collapse key')
parser.add_argument('--sound', required=False, default="default", help='Notification sound')
parser.add_argument('--action-label', required=False, default="View", help='Action Button Text')
parser.add_argument('--show-action-in-push', required=False, default="true", help='Show action in push')
parser.add_argument('--show-action-in-app', required=False, default="true", help='Show action in app')
parser.add_argument('--category', required=False, default="WHAT'S NEW", help='In-App category badge label')

args = parser.parse_args()
send_notification(args)
try:
send_notification(argument_parser().parse_args())
except (ValueError, OSError) as error:
print(f"Announcement stopped: {error}", file=sys.stderr)
sys.exit(1)
45 changes: 45 additions & 0 deletions .github/scripts/preview_announcement_on_device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Render an exported announcement on an isolated updateTest build over ADB.

No Firebase, workflow dispatch, topic message or production release is involved.
The receiver exists only in updateTest and requires Android's shell permission.
Delivery eligibility is tested separately: this local visual preview resets the
release code to 0 so an already-current test device can display the same layout.
"""
import argparse
import json
import shlex
import subprocess
from pathlib import Path

COMPONENT = 'cx.aswin.boxlore/.testing.AnnouncementTestReceiver'
ACTION = 'cx.aswin.boxlore.TEST_ANNOUNCEMENT'


def preview(preview_path, adb='adb', serial=None, runner=subprocess.run):
value = json.loads(Path(preview_path).read_text(encoding='utf-8'))
data = value.get('data')
if not isinstance(data, dict) or not data.get('title') or not data.get('body'):
raise ValueError('Choose an exported announcement-preview JSON file')
command = [adb] + (['-s', serial] if serial else [])
# ADB forwards a shell command. Quote every remote argument so copy remains
# exact, including spaces, apostrophes and shell metacharacters.
remote = shlex.join(['am', 'broadcast', '-n', COMPONENT, '-a', ACTION,
'--es', 'payload', json.dumps(data, ensure_ascii=False)])
result = runner(command + ['shell', remote], check=True, text=True, capture_output=True)
# An absent isolated receiver is a failure, not a successful preview on a normal install.
if 'result=-1' not in result.stdout or any(word in result.stdout + result.stderr for word in ('Error', 'Exception', 'Permission Denial')):
raise ValueError('Install the isolated updateTest APK first, then retry this local preview')
return result.stdout


if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--preview', type=Path, required=True)
parser.add_argument('--adb', default='adb')
parser.add_argument('--serial')
args = parser.parse_args()
try:
print(preview(args.preview, args.adb, args.serial))
print('Local preview requested. Open boxlore on the test device; no message was sent to users.')
except (ValueError, OSError, subprocess.CalledProcessError) as error:
parser.exit(1, f'Preview stopped: {error}\n')
Loading
Loading